Files
multica/server/pkg/db/queries/runtime_profile.sql
T
2aa20b0353 ZIC-245: fix custom Oh-My-Pi runtime profile identity and discovery (#8576)
* fix(runtimes): preserve custom profile base runtime identity

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

* fix(runtimes): pass both url params to the profile identity test

TestUpdateRuntimeProfile_RejectsIdentityChange nested two withURLParam
calls. That helper installs a fresh chi route context each time and does
not copy what is already there, so the outer call for profileId dropped
id: the handler answered 400 "workspace_id is required" and never
reached the immutability check the test exists to cover.

Use withURLParams, which carries existing params forward and is what the
rest of this file already uses.

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

* fix(runtimes): name a runtime target the same way on every surface

The base-runtime picker spelled Oh-My-Pi with an inline ternary, so it
was the only place that used the product name. The catalog row, both
detail headers, the base-runtime detail row and the locked chip in the
form kept rendering the stored id under `capitalize` — a user picked
"Oh-My-Pi" and the next screen, and the saved profile, said "Omp".

Move the name into runtimeTypeLabel next to the target whitelist and
read it from every surface. profileRuntimeType replaces the repeated
runtime_type ?? protocol_family fallback.

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-09-20 12:53:52 +08:00

178 lines
7.7 KiB
SQL

-- Custom Runtime profiles (MUL-3284). Workspace-level definitions of a custom
-- runtime; see migration 120 for the table. Relational integrity (workspace,
-- created_by) is enforced in the application layer — there are no DB FKs.
-- name: CreateRuntimeProfile :one
INSERT INTO runtime_profile (
workspace_id,
display_name,
protocol_family,
command_name,
description,
fixed_args,
visibility,
created_by,
enabled,
runtime_type
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
-- name: GetRuntimeProfile :one
SELECT * FROM runtime_profile
WHERE id = $1;
-- name: GetRuntimeProfileForWorkspace :one
SELECT * FROM runtime_profile
WHERE id = $1 AND workspace_id = $2;
-- name: LockRuntimeProfileForRegistration :one
-- Serializes daemon registration with profile deletion. Registration holds a
-- KEY SHARE lock until its runtime row is committed; profile deletion takes an
-- UPDATE lock, then locks the profile's runtime rows. Whichever starts first
-- wins, so deletion cannot miss a runtime inserted from a stale profile read.
SELECT * FROM runtime_profile
WHERE id = $1 AND workspace_id = $2
FOR KEY SHARE;
-- name: LockRuntimeProfileForDelete :one
-- See LockRuntimeProfileForRegistration. The stronger lock prevents a daemon
-- from registering another instance between the delete plan and commit.
SELECT * FROM runtime_profile
WHERE id = $1 AND workspace_id = $2
FOR UPDATE;
-- name: ListRuntimeProfiles :many
SELECT * FROM runtime_profile
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: ListEnabledRuntimeProfilesForWorkspace :many
-- Daemon-facing list: only enabled profiles are candidates for a daemon to
-- resolve on PATH and register. Ordered for stable output.
SELECT * FROM runtime_profile
WHERE workspace_id = $1 AND enabled = true
ORDER BY created_at ASC;
-- name: UpdateRuntimeProfile :one
-- Partial update via COALESCE: NULL args leave the column unchanged. The
-- protocol_family is intentionally NOT updatable — changing the underlying
-- backend of an existing profile would silently repoint every agent bound to
-- it onto a different protocol; callers create a new profile instead.
UPDATE runtime_profile
SET display_name = COALESCE(sqlc.narg('display_name'), display_name),
command_name = COALESCE(sqlc.narg('command_name'), command_name),
description = COALESCE(sqlc.narg('description'), description),
fixed_args = COALESCE(sqlc.narg('fixed_args'), fixed_args),
visibility = COALESCE(sqlc.narg('visibility'), visibility),
enabled = COALESCE(sqlc.narg('enabled'), enabled),
updated_at = now()
WHERE id = @id AND workspace_id = @workspace_id
RETURNING *;
-- name: DeleteRuntimeProfile :exec
DELETE FROM runtime_profile
WHERE id = $1 AND workspace_id = $2;
-- name: DeleteAgentRuntimesByProfile :many
-- Application-layer cascade: migration 120 dropped the DB ON DELETE CASCADE, so
-- the profile-delete path must remove the profile's registered runtime
-- instances itself. Returns the deleted rows so the caller can broadcast /
-- audit. Runs inside the same transaction as DeleteRuntimeProfile.
DELETE FROM agent_runtime
WHERE profile_id = $1 AND workspace_id = $2
RETURNING id, workspace_id, owner_id, daemon_id, provider;
-- name: ListActiveAgentsByProfile :many
-- Active (non-archived) agents bound to any runtime instance of this profile.
-- The profile-delete path uses this to refuse deletion (409) while agents
-- still depend on it, mirroring the runtime-delete guard.
--
-- It returns the rows rather than a count because the refusal has to name
-- them: a profile spans every machine that registered it, so the agents
-- blocking the delete are routinely bound to a different machine than the one
-- the user was trying to clean up, and a bare count gives them no way to tell
-- (GH #8456). Carrying the runtime is what lets the message say which machine.
--
-- Deliberately not filtered by kind: it defines when deletion is refused, and
-- narrowing it to user agents here would let a profile with a bound builder
-- carrier through, whereupon TeardownRuntime would hard-delete that carrier.
--
-- system_key rides along because it, not kind, decides what the user can
-- actually do about a blocker. Mika is kind='user' with system_key='mika' and
-- can be neither archived nor moved; a builder carrier is kind='system' and is
-- released by its Builder session, not from the agent list.
--
-- Bounded on purpose. The caller only needs to know that blockers exist, name a
-- few, and report how many there are — it never needs every row. This runs
-- inside the delete transaction while profile, runtime and agent rows are
-- locked, and the response is buffered whole before it is written, so an
-- unbounded read here would make both the time under lock and the response body
-- grow with the number of agents a profile has accumulated across machines.
--
-- The per-class counts are what let the refusal stay correct while bounded.
-- Rows are ordered by machine and name, so which blockers land inside the LIMIT
-- is arbitrary with respect to class: twenty ordinary agents can push the one
-- Mika to position 21. A message whose advice came from the visible rows would
-- then tell the user to archive all of them — the unactionable instruction this
-- whole change removes, just deferred. So the recovery paths are chosen from
-- these counts and only the names come from the rows.
--
-- Every count is a window function over the pre-LIMIT result, so they stay
-- exact no matter how small max_rows is. blocker_class is emitted per row as
-- well, giving the classification one definition that the Go classifier is
-- pinned against in tests.
WITH blockers AS (
SELECT
a.id,
a.name,
a.kind,
a.system_key,
ar.id AS runtime_id,
ar.name AS runtime_name,
ar.custom_name AS runtime_custom_name,
ar.status AS runtime_status,
CASE
WHEN a.system_key IS NULL OR btrim(a.system_key) = '' THEN 'user'
WHEN btrim(a.system_key) = 'mika' THEN 'mika'
-- starts_with, not LIKE: '_' is a single-character wildcard in
-- LIKE, so 'agent_builder:%' also matches 'agent-builder:x' and
-- 'agentXbuilder:x'. Those would be classified here as carriers
-- and by the Go side as other_system, and the refusal would send
-- the user to an Agent Builder session that does not exist.
WHEN starts_with(btrim(a.system_key), 'agent_builder:') THEN 'agent_builder'
ELSE 'other_system'
END AS blocker_class
FROM agent a
JOIN agent_runtime ar ON ar.id = a.runtime_id
WHERE ar.profile_id = $1 AND ar.workspace_id = $2 AND a.archived_at IS NULL
)
SELECT
id,
name,
kind,
system_key,
runtime_id,
runtime_name,
runtime_custom_name,
runtime_status,
blocker_class,
count(*) OVER () AS total_count,
count(*) FILTER (WHERE blocker_class = 'user') OVER () AS user_count,
count(*) FILTER (WHERE blocker_class = 'mika') OVER () AS mika_count,
count(*) FILTER (WHERE blocker_class = 'agent_builder') OVER () AS agent_builder_count,
count(*) FILTER (WHERE blocker_class = 'other_system') OVER () AS other_system_count
FROM blockers
ORDER BY runtime_name ASC, name ASC
LIMIT @max_rows::int;
-- name: ListAgentRuntimeIDsByProfile :many
-- Enumerates the runtime instance rows registered against a profile. The
-- profile-delete cascade walks these so it can run the same archived-agent /
-- archived-squad / autopilot teardown the runtime-delete path uses before
-- removing each runtime row — agent.runtime_id is ON DELETE RESTRICT, so a
-- bare delete would 500 whenever an archived agent still references the row.
SELECT id FROM agent_runtime
WHERE profile_id = $1 AND workspace_id = $2
ORDER BY id
FOR UPDATE;