mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
Deleting a profile-backed runtime instance is refused while its profile exists, and the refusal said "delete its runtime profile instead". For the case that produces it most often — a retired machine's leftover row in a profile other, healthy machines still use — that points at a workspace-wide delete which takes those machines' runtimes too, and which a bound agent refuses anyway with a second message naming no agent and no machine. Retention GC already deletes these rows after 7 days offline, ignoring profile_id, so the refusals now describe that instead of recommending the destructive route: - Instance refusal is status-aware, and only promises automatic cleanup when it matches the gate gcRuntime actually applies: the runtime's own undrained tasks plus those owned by every user agent bound to it, archived included. - Profile refusal names the blocking agents and the machine each sits on, with a per-class remedy — ordinary agents can be reassigned or archived; Mika cannot be archived but can be rebound; a Builder session is reachable only by its creator. Those clauses come from counts over the whole blocker set, so a class outside the 20-row sample is still reported. - Response bounded at 20 entries with an exact total, capping rows read inside the locked delete transaction. - CLI surfaces the server's sentence instead of dumping the raw JSON body. When a delete is allowed or refused is unchanged; the guard predicates and the instance-refusal error code are untouched. The remaining capability gap — retiring an offline instance on demand — is tracked separately. Fixes MUL-7409
176 lines
7.6 KiB
Go
176 lines
7.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// agentBuilderSystemKeyPrefix marks the hidden execution carrier behind an
|
|
// unfinished AI agent-creation flow. The suffix is the flow id.
|
|
const agentBuilderSystemKeyPrefix = "agent_builder:"
|
|
|
|
// A runtime or profile delete is refused while agents are still bound. Telling
|
|
// the user to "reassign or archive them" is only true for agents they can
|
|
// actually reach: the archive endpoint rejects anything carrying a system_key
|
|
// outright, and a builder carrier is not in the agent list at all. Naming one
|
|
// remedy for every blocker therefore hands some users an instruction that
|
|
// cannot be carried out — the same class of defect this whole change set exists
|
|
// to remove, so the refusals classify their blockers instead.
|
|
//
|
|
// system_key, not kind, is the discriminator. Mika is deliberately kind='user'
|
|
// (it must stay visible and assignable) while still being product-owned and
|
|
// unarchivable; builder carriers are kind='system'.
|
|
type blockingAgentClass int
|
|
|
|
const (
|
|
// blockingAgentUser is an ordinary workspace agent: rebind or archive it.
|
|
blockingAgentUser blockingAgentClass = iota
|
|
// blockingAgentMika is the workspace's built-in Mika. It cannot be archived
|
|
// (agent.go rejects any system_key there) but it CAN be rebound: UpdateAgent
|
|
// takes runtime_id for it like any other manageable agent. Those two halves
|
|
// have to be stated separately or the remedy is wrong in one direction.
|
|
blockingAgentMika
|
|
// blockingAgentBuilderCarrier is the hidden carrier behind an unfinished
|
|
// Agent Builder flow. It is released through that session, not the agent list.
|
|
blockingAgentBuilderCarrier
|
|
// blockingAgentOtherSystem is any future product-owned agent. Unarchivable
|
|
// like the rest, with no remedy this code can name specifically.
|
|
blockingAgentOtherSystem
|
|
)
|
|
|
|
// Class keys shared with the SQL CASE in ListActiveAgentsByProfile. That query
|
|
// has to classify server-side so its per-class counts can cover rows the LIMIT
|
|
// excludes, which means the mapping exists in two places; these constants give
|
|
// both the same vocabulary, and TestBlockingAgentClassMatchesSQLClassification
|
|
// pins them to each other.
|
|
const (
|
|
blockingAgentClassKeyUser = "user"
|
|
blockingAgentClassKeyMika = "mika"
|
|
blockingAgentClassKeyBuilder = "agent_builder"
|
|
blockingAgentClassKeyOtherSystem = "other_system"
|
|
)
|
|
|
|
// Unknown keys fall to other_system, not user. Only the exact "user" key may
|
|
// produce the one class whose remedy claims an action ("reassign or archive"):
|
|
// if a future class is added to the SQL CASE and not here, the refusal should
|
|
// say it cannot name a remedy rather than confidently hand out one that does
|
|
// not apply.
|
|
func blockingAgentClassFromKey(key string) blockingAgentClass {
|
|
switch key {
|
|
case blockingAgentClassKeyUser:
|
|
return blockingAgentUser
|
|
case blockingAgentClassKeyMika:
|
|
return blockingAgentMika
|
|
case blockingAgentClassKeyBuilder:
|
|
return blockingAgentBuilderCarrier
|
|
default:
|
|
return blockingAgentOtherSystem
|
|
}
|
|
}
|
|
|
|
func classifyBlockingAgent(systemKey pgtype.Text) blockingAgentClass {
|
|
key := strings.TrimSpace(systemKey.String)
|
|
if !systemKey.Valid || key == "" {
|
|
return blockingAgentUser
|
|
}
|
|
switch {
|
|
case key == service.MikaSystemKey:
|
|
return blockingAgentMika
|
|
case strings.HasPrefix(key, agentBuilderSystemKeyPrefix):
|
|
return blockingAgentBuilderCarrier
|
|
default:
|
|
return blockingAgentOtherSystem
|
|
}
|
|
}
|
|
|
|
// blockingAgentLabel renders one blocker for a refusal sentence. Product-owned
|
|
// agents are marked so the reader can tell at a glance why the plain remedy
|
|
// does not apply to them.
|
|
func blockingAgentLabel(name, runtimeName, runtimeStatus string, class blockingAgentClass) string {
|
|
switch class {
|
|
case blockingAgentBuilderCarrier:
|
|
return fmt.Sprintf("an unfinished Agent Builder session on %q (%s)", runtimeName, runtimeStatus)
|
|
case blockingAgentMika, blockingAgentOtherSystem:
|
|
return fmt.Sprintf("%q on %q (%s, built into Multica)", name, runtimeName, runtimeStatus)
|
|
default:
|
|
return fmt.Sprintf("%q on %q (%s)", name, runtimeName, runtimeStatus)
|
|
}
|
|
}
|
|
|
|
// blockingAgentScope distinguishes the two refusals, because one remedy differs
|
|
// between them: moving Mika off this runtime clears an instance refusal, but a
|
|
// profile refusal only clears if the destination is not another runtime of the
|
|
// same profile.
|
|
type blockingAgentScope int
|
|
|
|
const (
|
|
blockingAgentScopeInstance blockingAgentScope = iota
|
|
blockingAgentScopeProfile
|
|
)
|
|
|
|
// blockingAgentRemedies returns one clause per distinct recovery path present,
|
|
// in a fixed order so the sentence is stable. An empty result means every
|
|
// blocker was product-owned and the caller should say so rather than suggest
|
|
// an action.
|
|
func blockingAgentRemedies(classes map[blockingAgentClass]bool, scope blockingAgentScope) []string {
|
|
// "Reassign or archive them" must not appear to cover the product-owned
|
|
// blockers listed beside them, which is exactly the instruction they cannot
|
|
// follow. When both are present the clause names which ones it applies to.
|
|
mixed := classes[blockingAgentMika] ||
|
|
classes[blockingAgentBuilderCarrier] ||
|
|
classes[blockingAgentOtherSystem]
|
|
|
|
var out []string
|
|
if classes[blockingAgentUser] && mixed {
|
|
out = append(out, "The agents above that are not marked as built into Multica can be reassigned or archived.")
|
|
} else if classes[blockingAgentUser] {
|
|
out = append(out, "Reassign or archive them first.")
|
|
}
|
|
if classes[blockingAgentBuilderCarrier] {
|
|
// Addressed to the creator, not to whoever hit this error. Builder
|
|
// sessions are creator-scoped: ListAgentBuilderSessions returns 200 but
|
|
// omits other members' sessions, and switch/discard go through
|
|
// loadChatSessionForUser and return 403. So an admin who is not the
|
|
// creator cannot even see the session to act on it, and telling them to
|
|
// reopen it would be another instruction that cannot be carried out.
|
|
out = append(out, "The unfinished Agent Builder session(s) here are hidden from the agent list, and only their creator can open them — ask the member who started the session to switch its runtime or discard it; another admin cannot do that for them.")
|
|
}
|
|
if classes[blockingAgentMika] {
|
|
// Mika is unarchivable but NOT immovable: UpdateAgent takes runtime_id
|
|
// for it like any other agent an admin can manage (the only system_key
|
|
// guard in agent.go is on archive), and the agent detail page shows it
|
|
// an editable runtime picker. An earlier version of this message said
|
|
// there was no supported way to move it, which told an owner who could
|
|
// have fixed this in one edit to give up instead — the exact failure
|
|
// this whole change set is about. TestMikaRemedyMatchesWhatMikaCanDo
|
|
// exercises both halves against the real endpoints so the claim cannot
|
|
// drift from the product again.
|
|
target := "another runtime"
|
|
if scope == blockingAgentScopeProfile {
|
|
target = "a runtime that this profile does not provide"
|
|
}
|
|
out = append(out, fmt.Sprintf(
|
|
"Mika is built into Multica, so it cannot be archived — but it can be moved: open Mika's agent page and bind it to %s.",
|
|
target,
|
|
))
|
|
}
|
|
if classes[blockingAgentOtherSystem] {
|
|
out = append(out, "Some blockers are agents built into Multica and cannot be archived.")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// blockingAgentClassesFromAgents collects the classes present on a plain agent
|
|
// set, for the callers that already hold db.Agent rows.
|
|
func blockingAgentClassesFromAgents(agents []db.Agent) map[blockingAgentClass]bool {
|
|
classes := make(map[blockingAgentClass]bool, 2)
|
|
for _, a := range agents {
|
|
classes[classifyBlockingAgent(a.SystemKey)] = true
|
|
}
|
|
return classes
|
|
}
|