mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-07 11:14:28 +02:00
* feat(agents): make AI agent creation resumable (#6246) Leaving the Agent Creation Studio destroyed the conversation. The unmount cleanup called deleteChatSession, so a sidebar click, a tab close or a route change deleted the builder session and every message in it — the bug external users reported. Archiving instead (PR #6247) would have stopped the deletion without giving anyone a way back in: builder sessions hang off a hidden `kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of every chat list, so an archived one is unreachable rather than recoverable. A creation conversation is now a durable object with its own address. Server: - GET /api/agent-builder/sessions lists the caller's unfinished creations. Creator-scoped like every other chat read. It reports the CARRIER's runtime, not chat_session.runtime_id — the latter is the daemon's resume pointer and is deliberately left stale after a switch, so resuming from it would put the picker on a runtime that executes nothing (MUL-5163). - PUT /api/agent-builder/sessions/{id}/draft stores the configuration, including the edits the user typed but never sent. Migration 251 adds agent_builder_draft (no FK per repo rule; pruned explicitly by DeleteChatSession, the runtime teardown and the workspace teardown, and registered in the workspace-deletion manifest). - The payload is opaque to the server: its shape is the studio's AgentDraft, validated client-side. Teaching Postgres and the handler about it would create a second definition to keep in sync for no gain. Client: - The session id lives in `?session=`, so a refresh, a back/forward and a reopened tab land back in the same conversation. - Leaving no longer deletes anything. The only destructive path is an explicit "discard", confirmed in a dialog, next to the create button. - Creating the agent archives the conversation instead of deleting it: it is the record of how that agent was designed, and an idle carrier costs nothing since usage is booked per task. - The configuration autosaves (debounced) and restores on arrival, with the applied-assistant-message marker stored alongside it so a restore cannot re-apply the last reply over edits made after it. - The 1.5s polling of messages and pending-task is gone. The global realtime sync already invalidates both per session id, exactly as it does for the main chat window, which has never polled. - The `<agent_draft>` block collapses to one "configuration updated" line. The regex now also swallows an unterminated block, which is what streaming produces — the raw payload used to scroll past on every turn. The 2185-line agent-creation-studio.tsx is split into three routes (`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves to packages/core/agents/ with its tests, and the unreachable template flow — `setMode("templates")` had no caller — is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): let the builder panes resize The conversation / configuration split was not draggable. Two structural reasons, both fixed by giving the group the same shape the chat page uses: - The panels reached the group through BuilderWorkspace's fragment, so they were not children the group could measure. - The group's children alternated between one panel (runtime setup) and two (conversation), under one persisted layout id. The group now lives inside BuilderWorkspace with its two panels as its only children, and the setup screen renders no group at all — it has nothing to split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): give the resize handle a cursor on hover The separator had no cursor of its own, so the only signal that a split was draggable arrived after the drag started — the library writes a global `cursor: ... !important` while dragging, and nothing before it. Fixed on the shared handle rather than at one call site: every split surface (chat, inbox, issue detail, project detail, the agent builder) was missing the same affordance. The library's drag-time rule still outranks this one, so the cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(agents): render the builder's draft block as an inspectable row Every builder reply ends in an <agent_draft> block that rewrites the form on the right. Flattening it to a line of prose said that something changed but not what, and the payload — the only record of what the builder actually claimed — was unreachable. A settled reply now carries a full-width row saying the configuration was updated, which opens the exact payload. A streaming one keeps a text line instead: the block is still being written, so there is nothing complete to open, and without the line the half-finished JSON scrolls past. ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per surface and undefined everywhere but this one, because no other chat speaks this protocol — the alternative was to keep pushing an embedded protocol through `transformContent`, which can only ever produce prose. `extractBuilderDraftBlock` returns an unparseable payload verbatim rather than withholding it: a malformed block is exactly when someone wants to read it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): address review blockers on the resumable builder 1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered to 252. 2. #6287 — the manual form still lost everything. The route split moved where you land, not what survives: the draft was `useState`, so a tab switch (the desktop shell mounts only the active tab) remounted it empty, and the beforeunload guard covered a hard reload and nothing else. It now persists through the repo's draft-store factory, scoped by what is being created — a blank agent and a copy of agent X are different work, and a copy of X is not a copy of Y — cleared once the agent is committed, and registered for logout / workspace-delete cleanup. 3. A saved draft with no messages was unreachable. The configuration form is editable from the moment a builder session exists and autosaves, so someone could open it, type a name and leave before the first turn; the list keyed "is this a draft" on messages alone, so that row existed and nothing could reach it. A session now qualifies on a message OR a stored draft, and sorts by whichever it has. 4. The debounce dropped the last edits. Its timer died with the component, so navigating away inside the 800ms window lost exactly the keystrokes the user had just made. The pending payload is now flushed on unmount. `useUnsavedDraftWarning` is gone with its last caller: both routes persist, so the browser prompt would have been warning about work that is already saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): stop the resize cursor flipping mid-drag The library narrows its cursor the moment a panel hits a bound — col-resize while both directions are open, a one-way arrow once only one is. Truthful, but it reads as a glitch: the icon changes under your hand halfway through a drag you never stopped making. `disableCursor` turns that global rule off; the handle's own `cursor-col-resize` is now the only source. A drag captures the pointer and walks it across the panels, away from the 8px handle, so the group carries the same cursor for as long as a separator is active — otherwise it would fall back to a text caret the instant the pointer left the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): key manual drafts by owner, drop the draft-block rendering Two changes. One slot destroyed the other flow's work. The manual draft was stored under a single key: opening a blank form, or a copy of a different agent, refused to adopt the stored draft and then immediately wrote its own empty form over it — so a half-finished copy of agent A died the moment the user opened anything else, before typing a character. Drafts are now keyed by what is being created, the same shape the chat composer uses for its per-session drafts, and a slot is dropped when its content is gone rather than parked blank (which also stops the map growing a dead key per agent ever opened for duplication). Committing an agent clears that flow's slot only. The `<agent_draft>` block goes back to being hidden outright. Labelling it and opening its payload dressed up machinery as content: the block drives the configuration form, and the form is where its effect is already visible. `renderAssistantAddon` goes with it — ChatMessageList is back to what it was, since no surface needs the slot. The two-pattern strip stays: an unterminated block is what streaming produces, and without matching it the raw JSON scrolled past the reader on every turn. Also removed six barrel exports nothing imported through, and unexported five types only their own file used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): keep a manual draft whose only edit is a picker The "is this worth storing" predicate listed six fields by name, and the draft serializes eleven. A form whose only change was the model, the thinking level, the service tier, the access scope or a team grant read as untouched, so the next save deleted its slot — picking a model before typing a name and switching tabs lost the model. Enumerating was the mistake, not the specific omissions: the predicate stops covering every field added after it is written, and the failure is invisible because each field saves correctly as long as some *other* field is also set. It now compares the whole draft against a fresh one. The runtime stays outside that comparison, on the entry rather than in the draft, because the form seeds it on every visit and counting it would store a draft for a form nobody touched. Covered field by field, one edit at a time, so a future field cannot quietly fall out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
466 lines
18 KiB
Go
466 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
const agentBuilderInstructions = `You are Multica Agent Builder. Help the user design one practical AI agent through a short conversation.
|
|
|
|
Your job is to propose and refine configuration, never to create resources yourself. Ask only questions that materially change behavior. Prefer making a reasonable draft immediately, then ask at most two focused questions per turn.
|
|
|
|
Every response MUST end with exactly one <agent_draft> JSON block using this shape:
|
|
<agent_draft>{"name":"","description":"","instructions":"","model":"","skill_ids":[],"permission_scope":"private","member_ids":[]}</agent_draft>
|
|
|
|
Rules:
|
|
- The JSON must be valid, compact JSON on one physical line. Do not wrap it in Markdown fences.
|
|
- Escape every line break inside instructions as \n. Never place a literal newline inside a JSON string.
|
|
- Preserve good existing draft fields supplied in the user's message unless the user asks to change them.
|
|
- name is concise and suitable for a workspace list.
|
|
- description is one sentence, at most 200 characters.
|
|
- instructions are a complete Markdown system prompt describing role, workflow, output, and constraints.
|
|
- model must be empty, preserve current_draft.model, or exactly match an id explicitly listed in AVAILABLE RUNTIME MODELS. Never use a model label as the id.
|
|
- When AVAILABLE RUNTIME MODELS is null or empty, preserve current_draft.model and never invent a model id.
|
|
- skill_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE SKILLS.
|
|
- permission_scope must be private, workspace, or members. Default to private unless the user explicitly requests sharing.
|
|
- member_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE MEMBERS, and only when permission_scope is members.
|
|
- Never request, expose, or place secrets, tokens, passwords, or environment-variable values in the draft.
|
|
- Do not claim that the agent has been created. The user must review and confirm the draft in the UI.`
|
|
|
|
type CreateAgentBuilderSessionRequest struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Model string `json:"model,omitempty"`
|
|
}
|
|
|
|
type CreateAgentBuilderSessionResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
BuilderAgentID string `json:"builder_agent_id"`
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
|
|
// CreateAgentBuilderSession starts a private configuration conversation on an
|
|
// existing runtime. A hidden system agent is the execution carrier because the
|
|
// chat/task pipeline is intentionally agent-backed; it never appears in normal
|
|
// agent lists and cannot be selected as an assignee.
|
|
func (h *Handler) CreateAgentBuilderSession(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateAgentBuilderSessionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
runtimeID := strings.TrimSpace(req.RuntimeID)
|
|
if runtimeID == "" {
|
|
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
|
return
|
|
}
|
|
|
|
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
runtime, ok := h.resolveBuilderRuntime(w, r, workspaceID, workspaceUUID, runtimeID, "start")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
flowID := uuid.NewString()
|
|
ownerUUID := parseUUID(userID)
|
|
model := strings.TrimSpace(req.Model)
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start agent builder session")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
// FOR KEY SHARE on the workspace row before creating the builder's chat_session
|
|
// — the creator half of the #5219 delete/create protocol, so a session cannot
|
|
// be created into a workspace mid-delete (see LockWorkspaceForChatSessionCreate).
|
|
if _, err := qtx.LockWorkspaceForChatSessionCreate(r.Context(), workspaceUUID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "workspace not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to lock workspace")
|
|
return
|
|
}
|
|
|
|
builder, err := qtx.CreateAgentBuilder(r.Context(), db.CreateAgentBuilderParams{
|
|
WorkspaceID: workspaceUUID,
|
|
Name: fmt.Sprintf(".multica-agent-builder-%s", flowID),
|
|
RuntimeMode: runtime.RuntimeMode,
|
|
RuntimeID: runtime.ID,
|
|
OwnerID: ownerUUID,
|
|
Instructions: agentBuilderInstructions,
|
|
Model: pgtype.Text{String: model, Valid: model != ""},
|
|
SystemKey: pgtype.Text{
|
|
String: fmt.Sprintf("agent_builder:%s", flowID),
|
|
Valid: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to prepare agent builder")
|
|
return
|
|
}
|
|
|
|
session, err := qtx.CreateChatSession(r.Context(), db.CreateChatSessionParams{
|
|
WorkspaceID: workspaceUUID,
|
|
AgentID: builder.ID,
|
|
CreatorID: ownerUUID,
|
|
Title: "Create an agent",
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create agent builder session")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit agent builder session")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, CreateAgentBuilderSessionResponse{
|
|
SessionID: uuidToString(session.ID),
|
|
BuilderAgentID: uuidToString(builder.ID),
|
|
RuntimeID: runtimeID,
|
|
})
|
|
}
|
|
|
|
// AgentBuilderSessionSummary is one unfinished agent-creation conversation.
|
|
type AgentBuilderSessionSummary struct {
|
|
SessionID string `json:"session_id"`
|
|
Title string `json:"title"`
|
|
// RuntimeID is the carrier's runtime — where this conversation actually
|
|
// executes. The client seeds its runtime picker from it so the picker can
|
|
// never disagree with what answers the next message (MUL-5163).
|
|
RuntimeID string `json:"runtime_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
// LastMessageContent is the raw stored message, still in the builder's wire
|
|
// format (the user side is a JSON envelope, the assistant side carries an
|
|
// <agent_draft> block). Decoding is the client's job: the protocol is
|
|
// defined by the studio and its prompt, and duplicating it here would give
|
|
// it a second, silently divergent implementation.
|
|
LastMessageContent string `json:"last_message_content"`
|
|
LastMessageRole string `json:"last_message_role"`
|
|
LastMessageAt string `json:"last_message_at"`
|
|
// Draft is the stored configuration, opaque to the server (see migration
|
|
// 252). It ships with the list rather than behind its own fetch because the
|
|
// studio renders this list beside the conversation it switches between, so
|
|
// the picked row's configuration must be in hand at click time. Null when
|
|
// the conversation has only ever been driven by the AI — the client then
|
|
// replays the last <agent_draft> block instead.
|
|
Draft json.RawMessage `json:"draft,omitempty"`
|
|
}
|
|
|
|
type ListAgentBuilderSessionsResponse struct {
|
|
Sessions []AgentBuilderSessionSummary `json:"sessions"`
|
|
}
|
|
|
|
// ListAgentBuilderSessions returns the caller's unfinished agent-creation
|
|
// conversations, newest activity first.
|
|
//
|
|
// This is the only way back to a builder session: they are hidden from every
|
|
// chat surface by the `kind = 'user'` agent filter, so before this endpoint the
|
|
// studio had to delete one on navigation or leak it forever. Creator-scoped
|
|
// like every other chat read — a workspace admin cannot list someone else's
|
|
// drafts, matching loadChatSessionForUser's rule.
|
|
func (h *Handler) ListAgentBuilderSessions(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.ListAgentBuilderSessionsByCreator(r.Context(), db.ListAgentBuilderSessionsByCreatorParams{
|
|
WorkspaceID: workspaceUUID,
|
|
CreatorID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent builder sessions")
|
|
return
|
|
}
|
|
|
|
sessions := make([]AgentBuilderSessionSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
sessions = append(sessions, AgentBuilderSessionSummary{
|
|
SessionID: uuidToString(row.ID),
|
|
Title: row.Title,
|
|
RuntimeID: uuidToString(row.RuntimeID),
|
|
CreatedAt: timestampToString(row.CreatedAt),
|
|
UpdatedAt: timestampToString(row.UpdatedAt),
|
|
LastMessageContent: row.LastMessageContent,
|
|
LastMessageRole: row.LastMessageRole,
|
|
LastMessageAt: timestampToString(row.LastMessageAt),
|
|
Draft: json.RawMessage(row.StoredDraft),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, ListAgentBuilderSessionsResponse{Sessions: sessions})
|
|
}
|
|
|
|
// maxAgentBuilderDraftBytes bounds one stored configuration. The largest honest
|
|
// field is the instruction markdown, which the create API itself caps well
|
|
// below this; the limit exists so a client bug cannot grow an unbounded row.
|
|
const maxAgentBuilderDraftBytes = 256 * 1024
|
|
|
|
type SaveAgentBuilderDraftRequest struct {
|
|
Draft json.RawMessage `json:"draft"`
|
|
}
|
|
|
|
// SaveAgentBuilderDraft stores the configuration a creation conversation has
|
|
// arrived at, including the edits the user typed but has not sent.
|
|
//
|
|
// The payload is opaque (see migration 252): its shape is the studio's
|
|
// AgentDraft, validated client-side, and nothing server-side reads a field.
|
|
// Whole-object last-write-wins is correct here because a conversation has one
|
|
// editor on one screen — a field-level merge could only reconstruct a state the
|
|
// user never saw.
|
|
func (h *Handler) SaveAgentBuilderDraft(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req SaveAgentBuilderDraftRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.Draft) == 0 {
|
|
writeError(w, http.StatusBadRequest, "draft is required")
|
|
return
|
|
}
|
|
if len(req.Draft) > maxAgentBuilderDraftBytes {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "draft is too large")
|
|
return
|
|
}
|
|
if !json.Valid(req.Draft) {
|
|
writeError(w, http.StatusBadRequest, "draft must be valid JSON")
|
|
return
|
|
}
|
|
|
|
// Creator-only, and only for a builder carrier — the same two gates the
|
|
// runtime switch applies. Without the carrier check this would be a way to
|
|
// hang arbitrary JSON off any chat session the caller owns.
|
|
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, chi.URLParam(r, "sessionId"))
|
|
if !ok {
|
|
return
|
|
}
|
|
if session.Status != "active" {
|
|
writeError(w, http.StatusBadRequest, "chat session is archived")
|
|
return
|
|
}
|
|
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load chat agent")
|
|
return
|
|
}
|
|
if !isAgentBuilderCarrier(agent) {
|
|
writeError(w, http.StatusNotFound, "agent builder session not found")
|
|
return
|
|
}
|
|
|
|
if _, err := h.Queries.UpsertAgentBuilderDraft(r.Context(), db.UpsertAgentBuilderDraftParams{
|
|
ChatSessionID: session.ID,
|
|
WorkspaceID: session.WorkspaceID,
|
|
Draft: req.Draft,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save agent builder draft")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// resolveBuilderRuntime loads a runtime the caller is allowed to execute a
|
|
// builder conversation on. Shared by session create and runtime switch so both
|
|
// enforce the same three gates in the same order: it exists in this workspace,
|
|
// this member may use it (private runtimes stay owner/admin-only), and it is
|
|
// online. verb names the attempted action in the offline error so the two call
|
|
// sites read naturally.
|
|
func (h *Handler) resolveBuilderRuntime(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID pgtype.UUID, runtimeID, verb string) (db.AgentRuntime, bool) {
|
|
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
|
|
if !ok {
|
|
return db.AgentRuntime{}, false
|
|
}
|
|
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
|
ID: runtimeUUID,
|
|
WorkspaceID: workspaceUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
|
return db.AgentRuntime{}, false
|
|
}
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return db.AgentRuntime{}, false
|
|
}
|
|
if !canUseRuntimeForAgent(member, runtime) {
|
|
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can use it")
|
|
return db.AgentRuntime{}, false
|
|
}
|
|
if runtime.Status != "online" {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("runtime must be online to %s an agent builder session", verb))
|
|
return db.AgentRuntime{}, false
|
|
}
|
|
return runtime, true
|
|
}
|
|
|
|
type SwitchAgentBuilderRuntimeRequest struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
|
|
type SwitchAgentBuilderRuntimeResponse struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
|
|
// SwitchAgentBuilderRuntime re-points a live builder conversation at another
|
|
// runtime. The live-draft runtime picker used to mutate React state only, so the
|
|
// UI could show runtime B while every subsequent message still enqueued against
|
|
// the carrier agent frozen to runtime A at session create time (MUL-5163).
|
|
//
|
|
// The rebind runs under LockChatSessionForRuntimeBind, the same row lock
|
|
// SendDirectChatMessage takes, so "no reply is in flight" and "the carrier now
|
|
// points at B" are decided in one serialised step. Without that lock a send that
|
|
// had already read runtime A could still land its task after this handler
|
|
// returned success — reproducing the exact inconsistency this endpoint exists to
|
|
// remove.
|
|
//
|
|
// chat_session.runtime_id is deliberately left pointing at the old runtime: the
|
|
// daemon only resumes a stored provider session when that pointer matches the
|
|
// claiming task's runtime, so leaving it stale is what makes B start a fresh
|
|
// provider session instead of resuming A's. Multica-side chat history and the
|
|
// draft are untouched.
|
|
func (h *Handler) SwitchAgentBuilderRuntime(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req SwitchAgentBuilderRuntimeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
runtimeID := strings.TrimSpace(req.RuntimeID)
|
|
if runtimeID == "" {
|
|
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
|
return
|
|
}
|
|
|
|
// Creator-only, like every other write on a chat session.
|
|
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, chi.URLParam(r, "sessionId"))
|
|
if !ok {
|
|
return
|
|
}
|
|
if session.Status != "active" {
|
|
writeError(w, http.StatusBadRequest, "chat session is archived")
|
|
return
|
|
}
|
|
|
|
// Only builder carriers may be rebound. A user-authored agent changes runtime
|
|
// through the agent update path, which has its own permission model — this
|
|
// endpoint must not become a second, weaker way in.
|
|
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load chat agent")
|
|
return
|
|
}
|
|
if !isAgentBuilderCarrier(agent) {
|
|
writeError(w, http.StatusNotFound, "agent builder session not found")
|
|
return
|
|
}
|
|
|
|
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
runtime, ok := h.resolveBuilderRuntime(w, r, workspaceID, workspaceUUID, runtimeID, "switch")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to switch agent builder runtime")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
if _, err := qtx.LockChatSessionForRuntimeBind(r.Context(), session.ID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "chat session not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to lock chat session")
|
|
return
|
|
}
|
|
|
|
// Checked under the lock, so a send cannot slip in behind it. A task that is
|
|
// still queued on an offline runtime also counts as pending — the client is
|
|
// expected to stop it first, which restores the message to the composer.
|
|
if _, err := qtx.GetPendingChatTask(r.Context(), session.ID); err == nil {
|
|
writeError(w, http.StatusConflict, "stop the current reply before switching runtime")
|
|
return
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusInternalServerError, "failed to check pending builder task")
|
|
return
|
|
}
|
|
|
|
// Model ids are per-runtime, so the carrier's model is cleared rather than
|
|
// carried over; the new runtime resolves its own default.
|
|
updated, err := qtx.RebindAgentBuilderRuntime(r.Context(), db.RebindAgentBuilderRuntimeParams{
|
|
ID: agent.ID,
|
|
RuntimeID: runtime.ID,
|
|
RuntimeMode: runtime.RuntimeMode,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "agent builder session not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to switch agent builder runtime")
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit agent builder runtime switch")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, SwitchAgentBuilderRuntimeResponse{
|
|
RuntimeID: uuidToString(updated.RuntimeID),
|
|
})
|
|
}
|
|
|
|
// isAgentBuilderCarrier reports whether an agent is a hidden builder execution
|
|
// carrier. Mirrors the kind/system_key guard the builder SQL statements carry, so
|
|
// the handler rejects a non-builder session before reaching the database rather
|
|
// than relying on an UPDATE matching zero rows.
|
|
func isAgentBuilderCarrier(agent db.Agent) bool {
|
|
return agent.Kind == "system" &&
|
|
agent.SystemKey.Valid &&
|
|
strings.HasPrefix(agent.SystemKey.String, "agent_builder:")
|
|
}
|