Compare commits

...

9 Commits

Author SHA1 Message Date
yushen
8d6b7663df MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.

- Server (runtime_profile.go): drop `visibility` from the create + update
  request structs; CreateRuntimeProfile always stores 'workspace'
  (runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
  removed validRuntimeProfileVisibility. The column + response field stay
  (always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
  the VisibilityOption component, the detail row, the visibility state, and the
  create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
  (profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
  Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
  the VISIBILITY list column; removed validateVisibility; stopped sending the
  field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
  visibility:"private" -> response and DB row are 'workspace'); CLI create test
  now asserts visibility is never sent.

Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.

Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 13:05:55 +08:00
yushen
8b68ef508f MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".

- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
  detail row, the create body field, the update patch field, and the form
  textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
  fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
  update and stopped sending `fixed_args`; updated the "no fields" message.
  Test now asserts the CLI never sends fixed_args.

Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.

Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 12:06:58 +08:00
yushen
a4102ac723 MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).

- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
  whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
  RuntimeProtocolFamily / RuntimeProfile types; api client
  list/get/create/update/deleteRuntimeProfile against
  /api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
  mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
  rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
  protocol family → step 2 display_name/command_name/description; edit form
  for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
  409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
  create/edit/delete flow is keyboard + screen-reader operable.

Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.

Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 11:44:33 +08:00
yushen
d655762e2f MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
  update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
  / unset-path for a per-machine command override. protocol-family validated
  client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
  validated; update only sends changed flags (protocol_family immutable);
  delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
  CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
  round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
  appendProfileRuntimes now prefers an override path when set AND executable,
  else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
  bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
  relative-path rejection, config preservation); cli/config round-trip;
  daemon prefers-override / falls-back-when-not-executable.

Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 11:44:19 +08:00
yushen
cadfae6ef1 MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)
Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.

Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.

- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
  per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
  ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
  DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
  deletes cleanly: 204, runtime + archived agent + profile gone) and
  TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).

Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 17:14:02 +08:00
yushen
077e40c638 MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command
Daemon-side half of custom runtime profiles, against the server contract on
this branch.

- client.go: GetRuntimeProfiles(workspaceID) -> GET
  /api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
  RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
  runtimeIndex carries it).
- daemon.go:
  * appendProfileRuntimes — called inside registerRuntimesForWorkspace before
    the empty-runtimes guard. Best-effort fetch (older server 404s are logged
    and swallowed; never fails registration). Per enabled profile: resolve
    command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
    skip+log when absent, best-effort version probe, record the resolved
    absolute path keyed by profile_id, and append a registration entry
    {name, type=protocol_family, version, status:online, profile_id}. A
    custom-only host (no built-in agents) still registers.
  * profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
    customCommandPathForRuntime helpers.
  * runTask: looks up the claimed task's RuntimeID -> profile command path and
    overrides the executable path, synthesizing an AgentEntry so a custom
    runtime runs even when the host has no built-in agent of the same
    provider. provider (=protocol_family) is unchanged so agent.New still
    selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
  recorded (custom-only host); profile skipped when command not on PATH;
  profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
  120 protocol_family CHECK.

Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).

Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:48:44 +08:00
yushen
1a285e94a5 MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration
Server/DB half of the custom-runtime feature.

- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
  constraint on agent_runtime into a partial unique index scoped to built-in
  rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
  lets one daemon host the built-in provider AND custom profiles of the same
  protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
  (daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
  app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
  UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
  targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
  whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
  protocol_family whitelist validation, display_name uniqueness (409), and
  fixed_args validation (no generic per-agent args — iron rule); a
  daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
  DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
  profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
  profile belongs to the workspace and is enabled, registers via the
  profile-aware upsert, and skips legacy hostname merge for custom rows.
  AgentRuntimeResponse now carries profile_id.

Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.

Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:30:05 +08:00
yushen
84b32e35ff MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):

- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
  -> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
  -> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
  -> plain UUID.

CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.

Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.

Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:05:11 +08:00
yushen
a86aecef80 MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:

- New workspace-level `runtime_profile` table: the shared, team-visible
  definition of a custom runtime (e.g. an in-house Codex wrapper).
  protocol_family is CHECK-constrained to the exact backend list in
  agent.New() (server/pkg/agent/agent.go). The only args column is
  `fixed_args` (args every agent on the runtime must inherit); there is
  deliberately no generic per-agent args field — those stay on
  agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
  CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
  a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
  (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.

The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.

Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:48:37 +08:00
39 changed files with 4641 additions and 58 deletions

View File

@@ -24,6 +24,9 @@ import type {
AgentActivityBucket,
AgentRunCount,
AgentRuntime,
RuntimeProfile,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
IssueSubscriber,
Comment,
@@ -1092,6 +1095,61 @@ export class ApiClient {
});
}
// ---------------------------------------------------------------------
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
// passes the workspace id the same way the runtimes list resolves it.
// ---------------------------------------------------------------------
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
`/api/workspaces/${workspaceId}/runtime-profiles`,
);
return res.runtime_profiles ?? [];
}
async getRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
);
}
async createRuntimeProfile(
workspaceId: string,
body: CreateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
method: "POST",
body: JSON.stringify(body),
});
}
async updateRuntimeProfile(
workspaceId: string,
profileId: string,
patch: UpdateRuntimeProfileRequest,
): Promise<RuntimeProfile> {
return this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{
method: "PATCH",
body: JSON.stringify(patch),
},
);
}
async deleteRuntimeProfile(
workspaceId: string,
profileId: string,
): Promise<void> {
await this.fetch(
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
{ method: "DELETE" },
);
}
async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },

View File

@@ -1,4 +1,5 @@
export * from "./queries";
export * from "./profiles";
export * from "./mutations";
export * from "./hooks";
export * from "./models";

View File

@@ -0,0 +1,103 @@
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { ApiError } from "../api";
import type {
CreateRuntimeProfileRequest,
RuntimeProfile,
UpdateRuntimeProfileRequest,
} from "../types/agent";
import { runtimeKeys } from "./queries";
// Query keys for the workspace-scoped custom runtime profile catalog. Kept
// separate from `runtimeKeys` (which key the registered runtime *instances*)
// because the two resources invalidate on different events — but a profile
// delete can archive bound agents and therefore must also invalidate the
// instance list, so the mutations below touch both.
export const runtimeProfileKeys = {
all: (wsId: string) => ["runtime-profiles", wsId] as const,
list: (wsId: string) => [...runtimeProfileKeys.all(wsId), "list"] as const,
detail: (wsId: string, profileId: string) =>
[...runtimeProfileKeys.all(wsId), "detail", profileId] as const,
};
export function runtimeProfileListOptions(wsId: string) {
return queryOptions({
queryKey: runtimeProfileKeys.list(wsId),
queryFn: () => api.listRuntimeProfiles(wsId),
});
}
export function useCreateRuntimeProfile(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateRuntimeProfileRequest) =>
api.createRuntimeProfile(wsId, body),
onSettled: () => {
qc.invalidateQueries({ queryKey: runtimeProfileKeys.all(wsId) });
},
});
}
export function useUpdateRuntimeProfile(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
profileId,
patch,
}: {
profileId: string;
patch: UpdateRuntimeProfileRequest;
}) => api.updateRuntimeProfile(wsId, profileId, patch),
onSettled: () => {
qc.invalidateQueries({ queryKey: runtimeProfileKeys.all(wsId) });
// A rename / visibility change can affect how the runtime list
// labels bound instances; refresh that too.
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
});
}
export function useDeleteRuntimeProfile(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (profileId: string) => api.deleteRuntimeProfile(wsId, profileId),
onSettled: () => {
qc.invalidateQueries({ queryKey: runtimeProfileKeys.all(wsId) });
// The strict DELETE refuses (409) while agents are bound, but once it
// succeeds the bound-instance picture may change — keep the runtime
// list in sync.
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
});
}
// The server returns a 409 with a machine-readable code when a delete is
// refused because active agents are still bound to the profile. We surface
// the server's human-readable message verbatim so the confirm dialog can
// explain the refusal without re-deriving it. Non-409s and unrelated codes
// collapse to `null` so callers fall through to the generic error path.
export interface RuntimeProfileBoundConflict {
message: string;
}
export function parseRuntimeProfileBoundConflict(
err: unknown,
): RuntimeProfileBoundConflict | null {
if (!(err instanceof ApiError)) return null;
if (err.status !== 409) return null;
const body = err.body;
const fallback = err.message;
if (body && typeof body === "object") {
const record = body as Record<string, unknown>;
const message =
typeof record.message === "string" && record.message.trim()
? record.message
: typeof record.error === "string" && record.error.trim()
? record.error
: fallback;
return { message };
}
return { message: fallback };
}
export type { RuntimeProfile };

View File

@@ -26,6 +26,14 @@ export interface RuntimeDevice {
owner_id: string | null;
/** Defaults to "private" when the backend predates the visibility flag. */
visibility: RuntimeVisibility;
/**
* The custom runtime profile this registered runtime was launched from,
* or `null` for a built-in protocol family. The UI uses this to stamp a
* "Built-in" vs "Custom" badge on the runtime row. Older backends that
* predate the custom-runtime feature omit the field; consumers must treat
* a missing value as `null` (built-in).
*/
profile_id?: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
@@ -33,6 +41,82 @@ export interface RuntimeDevice {
export type AgentRuntime = RuntimeDevice;
// ---------------------------------------------------------------------------
// Custom runtime profiles (MUL-3284)
//
// A RuntimeProfile is a workspace-level *definition* of a custom runtime
// backend — distinct from a RuntimeDevice, which is a daemon-registered
// *instance*. An admin authors a profile (display name + base protocol
// family + the CLI command to launch), and daemons can then register
// runtimes against it; those instances carry `profile_id` pointing back here.
// ---------------------------------------------------------------------------
// The fixed allow-list of base protocol families a custom runtime can wrap.
// These are the only backends the create flow may select; the server rejects
// anything else with 400. Kept as a const tuple so the union type is derived
// from the single source of truth.
export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
"claude",
"codebuddy",
"codex",
"copilot",
"opencode",
"openclaw",
"hermes",
"gemini",
"pi",
"cursor",
"kimi",
"kiro",
"antigravity",
] as const;
export type RuntimeProtocolFamily =
(typeof RUNTIME_PROFILE_PROTOCOL_FAMILIES)[number];
// Profile visibility mirrors RuntimeVisibility's vocabulary but uses the
// workspace/private axis the server documents for profiles.
export type RuntimeProfileVisibility = "workspace" | "private";
export interface RuntimeProfile {
id: string;
workspace_id: string;
display_name: string;
protocol_family: RuntimeProtocolFamily;
command_name: string;
description: string | null;
fixed_args: string[];
visibility: RuntimeProfileVisibility;
created_by: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
}
// POST body. `protocol_family` is required and immutable after creation.
// Optional fields are omitted entirely when unset (never sent as null/empty)
// so the server applies its own defaults.
export interface CreateRuntimeProfileRequest {
display_name: string;
protocol_family: RuntimeProtocolFamily;
command_name: string;
description?: string;
fixed_args?: string[];
visibility?: RuntimeProfileVisibility;
enabled?: boolean;
}
// PATCH body — every field optional; `protocol_family` is intentionally
// absent because it is immutable.
export interface UpdateRuntimeProfileRequest {
display_name?: string;
command_name?: string;
description?: string | null;
fixed_args?: string[];
visibility?: RuntimeProfileVisibility;
enabled?: boolean;
}
// Coarse classifier set by the backend when a task transitions to "failed".
// Mirrors the migration-055 enum in agent_task_queue.failure_reason. Used by
// the agent presence derivation and the UI failure-message lookup.

View File

@@ -10,6 +10,11 @@ export type {
TaskFailureReason,
AgentRuntime,
RuntimeDevice,
RuntimeProfile,
RuntimeProtocolFamily,
RuntimeProfileVisibility,
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
@@ -54,6 +59,7 @@ export type {
RuntimeLocalSkillImportResult,
IssueUsageSummary,
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";

View File

@@ -264,7 +264,72 @@
"row_actions_aria": "Row actions",
"delete_action": "Delete",
"delete_permission_hint": "Only the runtime owner and workspace admins can delete this runtime",
"delete_admin_hint": "Only the runtime owner and workspace admins can delete a runtime."
"delete_admin_hint": "Only the runtime owner and workspace admins can delete a runtime.",
"badge_builtin": "Built-in",
"badge_custom": "Custom"
},
"profiles": {
"cta": "Add runtime",
"dialog_title": "Custom runtimes",
"dialog_description": "Define custom runtime backends for your agents. Built-in families are shown for reference.",
"add_new": "New custom runtime",
"list_title": "Runtimes",
"empty_custom": "No custom runtimes yet. Built-in families are listed above.",
"badge_builtin": "Built-in",
"badge_custom": "Custom",
"badge_disabled": "Disabled",
"builtin_detail": {
"title": "Built-in runtime",
"description": "{{family}} is a built-in protocol family. It can't be edited or removed.",
"read_only": "Read-only"
},
"detail": {
"base_family": "Base protocol family",
"command": "Command",
"description": "Description",
"no_description": "No description",
"edit": "Edit",
"delete": "Delete",
"select_hint": "Select a runtime to see its details."
},
"form": {
"create_title": "New custom runtime",
"edit_title": "Edit custom runtime",
"step_family_label": "Choose a base protocol family",
"step_family_hint": "The underlying CLI protocol this runtime speaks.",
"step_details_label": "Configure the runtime",
"family_label": "Base protocol family",
"family_locked_hint": "The base protocol family can't be changed after creation.",
"display_name_label": "Display name",
"display_name_placeholder": "My custom Claude",
"command_name_label": "Command",
"command_name_placeholder": "claude",
"description_label": "Description",
"description_placeholder": "Optional — what this runtime is for",
"error_display_name_required": "Display name is required.",
"error_command_required": "Command is required.",
"back": "Back",
"cancel": "Cancel",
"next": "Next",
"create": "Create runtime",
"creating": "Creating…",
"save": "Save changes",
"saving": "Saving…",
"toast_created": "Custom runtime created",
"toast_updated": "Custom runtime updated",
"error_duplicate_name": "A runtime with this display name already exists.",
"error_generic": "Failed to save the runtime."
},
"delete_dialog": {
"title": "Delete custom runtime?",
"description": "Delete \"{{name}}\"? This can't be undone.",
"confirm": "Delete",
"cancel": "Cancel",
"deleting": "Deleting…",
"toast_deleted": "Custom runtime deleted",
"error_bound": "This runtime can't be deleted while agents are still using it.",
"error_generic": "Failed to delete the runtime."
}
},
"usage": {
"period_label": "Period",

View File

@@ -252,7 +252,72 @@
"row_actions_aria": "行の操作",
"delete_action": "削除",
"delete_permission_hint": "ランタイムの所有者とワークスペース管理者のみが、このランタイムを削除できます",
"delete_admin_hint": "ランタイムの所有者とワークスペース管理者のみが、ランタイムを削除できます。"
"delete_admin_hint": "ランタイムの所有者とワークスペース管理者のみが、ランタイムを削除できます。",
"badge_builtin": "組み込み",
"badge_custom": "カスタム"
},
"profiles": {
"cta": "ランタイムを追加",
"dialog_title": "カスタムランタイム",
"dialog_description": "エージェント用のカスタムランタイムバックエンドを定義します。組み込みファミリーは参考として表示されます。",
"add_new": "新しいカスタムランタイム",
"list_title": "ランタイム",
"empty_custom": "カスタムランタイムはまだありません。組み込みファミリーは上に表示されています。",
"badge_builtin": "組み込み",
"badge_custom": "カスタム",
"badge_disabled": "無効",
"builtin_detail": {
"title": "組み込みランタイム",
"description": "{{family}} は組み込みのプロトコルファミリーです。編集や削除はできません。",
"read_only": "読み取り専用"
},
"detail": {
"base_family": "ベースプロトコルファミリー",
"command": "コマンド",
"description": "説明",
"no_description": "説明なし",
"edit": "編集",
"delete": "削除",
"select_hint": "ランタイムを選択すると詳細が表示されます。"
},
"form": {
"create_title": "新しいカスタムランタイム",
"edit_title": "カスタムランタイムを編集",
"step_family_label": "ベースプロトコルファミリーを選択",
"step_family_hint": "このランタイムが使用する基盤の CLI プロトコル。",
"step_details_label": "ランタイムを設定",
"family_label": "ベースプロトコルファミリー",
"family_locked_hint": "作成後はベースプロトコルファミリーを変更できません。",
"display_name_label": "表示名",
"display_name_placeholder": "マイカスタム Claude",
"command_name_label": "コマンド",
"command_name_placeholder": "claude",
"description_label": "説明",
"description_placeholder": "任意 — このランタイムの用途",
"error_display_name_required": "表示名は必須です。",
"error_command_required": "コマンドは必須です。",
"back": "戻る",
"cancel": "キャンセル",
"next": "次へ",
"create": "ランタイムを作成",
"creating": "作成中…",
"save": "変更を保存",
"saving": "保存中…",
"toast_created": "カスタムランタイムを作成しました",
"toast_updated": "カスタムランタイムを更新しました",
"error_duplicate_name": "この表示名のランタイムは既に存在します。",
"error_generic": "ランタイムの保存に失敗しました。"
},
"delete_dialog": {
"title": "カスタムランタイムを削除しますか?",
"description": "「{{name}}」を削除しますか?この操作は元に戻せません。",
"confirm": "削除",
"cancel": "キャンセル",
"deleting": "削除中…",
"toast_deleted": "カスタムランタイムを削除しました",
"error_bound": "まだエージェントが使用しているため、このランタイムは削除できません。",
"error_generic": "ランタイムの削除に失敗しました。"
}
},
"usage": {
"period_label": "期間",

View File

@@ -264,7 +264,72 @@
"row_actions_aria": "행 작업",
"delete_action": "삭제",
"delete_permission_hint": "런타임 소유자와 워크스페이스 관리자만 이 런타임을 삭제할 수 있습니다",
"delete_admin_hint": "런타임 소유자와 워크스페이스 관리자만 런타임을 삭제할 수 있습니다."
"delete_admin_hint": "런타임 소유자와 워크스페이스 관리자만 런타임을 삭제할 수 있습니다.",
"badge_builtin": "기본 제공",
"badge_custom": "사용자 지정"
},
"profiles": {
"cta": "런타임 추가",
"dialog_title": "사용자 지정 런타임",
"dialog_description": "에이전트를 위한 사용자 지정 런타임 백엔드를 정의합니다. 기본 제공 제품군은 참고용으로 표시됩니다.",
"add_new": "새 사용자 지정 런타임",
"list_title": "런타임",
"empty_custom": "아직 사용자 지정 런타임이 없습니다. 기본 제공 제품군은 위에 표시됩니다.",
"badge_builtin": "기본 제공",
"badge_custom": "사용자 지정",
"badge_disabled": "비활성화됨",
"builtin_detail": {
"title": "기본 제공 런타임",
"description": "{{family}}은(는) 기본 제공 프로토콜 제품군입니다. 편집하거나 삭제할 수 없습니다.",
"read_only": "읽기 전용"
},
"detail": {
"base_family": "기본 프로토콜 제품군",
"command": "명령",
"description": "설명",
"no_description": "설명 없음",
"edit": "편집",
"delete": "삭제",
"select_hint": "런타임을 선택하면 세부 정보가 표시됩니다."
},
"form": {
"create_title": "새 사용자 지정 런타임",
"edit_title": "사용자 지정 런타임 편집",
"step_family_label": "기본 프로토콜 제품군 선택",
"step_family_hint": "이 런타임이 사용하는 기반 CLI 프로토콜입니다.",
"step_details_label": "런타임 구성",
"family_label": "기본 프로토콜 제품군",
"family_locked_hint": "생성 후에는 기본 프로토콜 제품군을 변경할 수 없습니다.",
"display_name_label": "표시 이름",
"display_name_placeholder": "내 사용자 지정 Claude",
"command_name_label": "명령",
"command_name_placeholder": "claude",
"description_label": "설명",
"description_placeholder": "선택 사항 — 이 런타임의 용도",
"error_display_name_required": "표시 이름은 필수입니다.",
"error_command_required": "명령은 필수입니다.",
"back": "뒤로",
"cancel": "취소",
"next": "다음",
"create": "런타임 생성",
"creating": "생성 중…",
"save": "변경 사항 저장",
"saving": "저장 중…",
"toast_created": "사용자 지정 런타임이 생성되었습니다",
"toast_updated": "사용자 지정 런타임이 업데이트되었습니다",
"error_duplicate_name": "이 표시 이름을 사용하는 런타임이 이미 있습니다.",
"error_generic": "런타임 저장에 실패했습니다."
},
"delete_dialog": {
"title": "사용자 지정 런타임을 삭제하시겠습니까?",
"description": "\"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"confirm": "삭제",
"cancel": "취소",
"deleting": "삭제 중…",
"toast_deleted": "사용자 지정 런타임이 삭제되었습니다",
"error_bound": "아직 에이전트가 사용 중이어서 이 런타임을 삭제할 수 없습니다.",
"error_generic": "런타임 삭제에 실패했습니다."
}
},
"usage": {
"period_label": "기간",

View File

@@ -252,7 +252,72 @@
"row_actions_aria": "行操作",
"delete_action": "删除",
"delete_permission_hint": "只有运行时所有者和工作区管理员可以删除这个运行时",
"delete_admin_hint": "只有运行时所有者和工作区管理员可以删除运行时。"
"delete_admin_hint": "只有运行时所有者和工作区管理员可以删除运行时。",
"badge_builtin": "内置",
"badge_custom": "自定义"
},
"profiles": {
"cta": "添加运行时",
"dialog_title": "自定义运行时",
"dialog_description": "为你的智能体定义自定义运行时后端。内置类型仅供参考。",
"add_new": "新建自定义运行时",
"list_title": "运行时",
"empty_custom": "暂无自定义运行时。内置类型已列在上方。",
"badge_builtin": "内置",
"badge_custom": "自定义",
"badge_disabled": "已禁用",
"builtin_detail": {
"title": "内置运行时",
"description": "{{family}} 是内置协议类型,无法编辑或删除。",
"read_only": "只读"
},
"detail": {
"base_family": "基础协议类型",
"command": "命令",
"description": "描述",
"no_description": "无描述",
"edit": "编辑",
"delete": "删除",
"select_hint": "选择一个运行时以查看详情。"
},
"form": {
"create_title": "新建自定义运行时",
"edit_title": "编辑自定义运行时",
"step_family_label": "选择基础协议类型",
"step_family_hint": "此运行时使用的底层 CLI 协议。",
"step_details_label": "配置运行时",
"family_label": "基础协议类型",
"family_locked_hint": "创建后无法更改基础协议类型。",
"display_name_label": "显示名称",
"display_name_placeholder": "我的自定义 Claude",
"command_name_label": "命令",
"command_name_placeholder": "claude",
"description_label": "描述",
"description_placeholder": "可选 — 此运行时的用途",
"error_display_name_required": "显示名称为必填项。",
"error_command_required": "命令为必填项。",
"back": "返回",
"cancel": "取消",
"next": "下一步",
"create": "创建运行时",
"creating": "正在创建…",
"save": "保存更改",
"saving": "正在保存…",
"toast_created": "自定义运行时已创建",
"toast_updated": "自定义运行时已更新",
"error_duplicate_name": "已存在使用此显示名称的运行时。",
"error_generic": "保存运行时失败。"
},
"delete_dialog": {
"title": "删除自定义运行时?",
"description": "确定删除“{{name}}”?此操作无法撤销。",
"confirm": "删除",
"cancel": "取消",
"deleting": "正在删除…",
"toast_deleted": "自定义运行时已删除",
"error_bound": "仍有智能体在使用此运行时,无法删除。",
"error_generic": "删除运行时失败。"
}
},
"usage": {
"period_label": "时间范围",

View File

@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import type { RuntimeProfile } from "@multica/core/types";
import {
parseRuntimeProfileBoundConflict,
useDeleteRuntimeProfile,
} from "@multica/core/runtimes";
import {
AlertDialog,
AlertDialogContent,
} from "@multica/ui/components/ui/alert-dialog";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "../../i18n";
// Confirmation dialog for deleting a custom runtime profile. The server
// refuses with a 409 when agents are still bound to the profile; we surface
// that refusal inline (and keep the dialog open) instead of dumping a raw
// error toast, so the admin can read why and back out gracefully.
export function DeleteRuntimeProfileDialog({
open,
onOpenChange,
profile,
wsId,
onDeleted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
profile: RuntimeProfile;
wsId: string;
onDeleted: () => void;
}) {
const { t } = useT("runtimes");
const deleteProfile = useDeleteRuntimeProfile(wsId);
const [submitting, setSubmitting] = useState(false);
// Server-issued "agents still bound" message, shown inline above the
// actions. Reset whenever the dialog re-opens.
const [boundMessage, setBoundMessage] = useState<string | null>(null);
useEffect(() => {
if (open) {
setSubmitting(false);
setBoundMessage(null);
}
}, [open]);
const handleOpenChange = (next: boolean) => {
if (submitting) return;
onOpenChange(next);
};
const handleConfirm = async () => {
setSubmitting(true);
setBoundMessage(null);
try {
await deleteProfile.mutateAsync(profile.id);
toast.success(t(($) => $.profiles.delete_dialog.toast_deleted));
onDeleted();
} catch (err) {
const conflict = parseRuntimeProfileBoundConflict(err);
if (conflict) {
// Prefer the server's specific wording; fall back to our localized
// generic "still bound" copy when the body carried no message.
setBoundMessage(
conflict.message ||
t(($) => $.profiles.delete_dialog.error_bound),
);
return;
}
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.profiles.delete_dialog.error_generic),
);
} finally {
setSubmitting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent
className="w-[calc(100vw-2rem)] !max-w-[440px] gap-0 overflow-hidden rounded-lg p-0"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 pb-4 pt-5">
<h2 className="text-base font-semibold">
{t(($) => $.profiles.delete_dialog.title)}
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{t(($) => $.profiles.delete_dialog.description, {
name: profile.display_name,
})}
</p>
{boundMessage && (
<div
role="alert"
className="mt-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 px-3 py-2 text-xs"
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-warning" />
<span className="text-foreground">{boundMessage}</span>
</div>
)}
</div>
<div className="border-t bg-muted/25 px-5 py-3">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => handleOpenChange(false)}
disabled={submitting}
>
{t(($) => $.profiles.delete_dialog.cancel)}
</Button>
<Button
type="button"
variant="destructive"
className="w-full sm:w-auto"
onClick={handleConfirm}
disabled={submitting}
>
{submitting
? t(($) => $.profiles.delete_dialog.deleting)
: t(($) => $.profiles.delete_dialog.confirm)}
</Button>
</div>
</div>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -179,12 +179,35 @@ function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
<span className="block min-w-0 shrink truncate text-sm font-medium">
{baseName}
</span>
<RuntimeKindBadge runtime={runtime} />
<VisibilityBadge runtime={runtime} />
</div>
</ListGridCell>
);
}
// Distinguishes a built-in protocol-family runtime from one launched off a
// custom runtime profile. `profile_id` is the discriminator: a non-null /
// non-empty value means the runtime was started from a custom profile.
// Older backends omit the field — treated as built-in.
function RuntimeKindBadge({ runtime }: { runtime: AgentRuntime }) {
const { t } = useT("runtimes");
const isCustom = !!runtime.profile_id;
return (
<span
className={
isCustom
? "inline-flex shrink-0 items-center rounded bg-info/10 px-1 text-[10px] font-medium text-info"
: "inline-flex shrink-0 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground"
}
>
{isCustom
? t(($) => $.list.badge_custom)
: t(($) => $.list.badge_builtin)}
</span>
);
}
// Only public is worth a badge — private is the default and rendering a
// `🔒 Private` chip on every row turns the whole column into noise.
function VisibilityBadge({ runtime }: { runtime: AgentRuntime }) {

View File

@@ -0,0 +1,90 @@
import {
RUNTIME_PROFILE_PROTOCOL_FAMILIES,
type RuntimeProfile,
type RuntimeProtocolFamily,
} from "@multica/core/types";
// A single row in the runtimes catalog the management dialog renders: the
// built-in protocol families ship as read-only reference rows, the custom
// profiles as editable rows. They render mixed in one list, each tagged with
// its kind so the row can stamp the right badge (built-in vs custom).
export type RuntimeCatalogEntry =
| {
kind: "builtin";
// Stable row id — the protocol family doubles as the key for built-ins.
id: string;
protocolFamily: RuntimeProtocolFamily;
}
| {
kind: "custom";
id: string;
protocolFamily: RuntimeProtocolFamily;
profile: RuntimeProfile;
};
// Re-export the whitelist as a typed array so callers (the family picker,
// the catalog builder) share the single source of truth.
export const PROTOCOL_FAMILIES: readonly RuntimeProtocolFamily[] =
RUNTIME_PROFILE_PROTOCOL_FAMILIES;
// buildRuntimeCatalog produces the mixed, flat list: every built-in family
// first (in whitelist order), then the custom profiles (alphabetical by
// display name, case-insensitive). No grouping / headers — the row badge is
// the only built-in-vs-custom signal, matching the locked progressive-
// disclosure design.
export function buildRuntimeCatalog(
profiles: RuntimeProfile[],
): RuntimeCatalogEntry[] {
const builtins: RuntimeCatalogEntry[] = PROTOCOL_FAMILIES.map((family) => ({
kind: "builtin" as const,
id: `builtin:${family}`,
protocolFamily: family,
}));
const customs: RuntimeCatalogEntry[] = [...profiles]
.sort((a, b) =>
a.display_name.localeCompare(b.display_name, undefined, {
sensitivity: "base",
}),
)
.map((profile) => ({
kind: "custom" as const,
id: profile.id,
protocolFamily: profile.protocol_family,
profile,
}));
return [...builtins, ...customs];
}
// NOTE: `fixed_args` is intentionally NOT exposed in the v1 UI. The server
// still carries the column, but the daemon does not yet splice these args into
// the agent launch command, so surfacing an input/display here would promise
// admins a behavior that does not exist. Re-introduce the parse/format helpers
// and the form field only once the daemon actually passes them to the backend
// (proven by a test). See TODO(MUL-3284) in server/internal/daemon/daemon.go.
export interface ProfileFormValues {
displayName: string;
commandName: string;
description: string;
}
export type ProfileFormErrorField = "displayName" | "commandName";
// Pure, synchronous validation for the create/edit form. Returns the set of
// invalid fields (empty = valid). Display name and command name are the only
// hard-required fields; description and fixed args are optional.
export function validateProfileForm(
values: ProfileFormValues,
): ProfileFormErrorField[] {
const errors: ProfileFormErrorField[] = [];
if (!values.displayName.trim()) errors.push("displayName");
if (!values.commandName.trim()) errors.push("commandName");
return errors;
}
// Returns true when the entry should be treated as a built-in (read-only).
export function isBuiltinEntry(entry: RuntimeCatalogEntry): boolean {
return entry.kind === "builtin";
}

View File

@@ -0,0 +1,741 @@
"use client";
import { useId, useMemo, useState } from "react";
import type { FormEvent } from "react";
import {
ChevronLeft,
Loader2,
Pencil,
Plus,
Server,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { ApiError } from "@multica/core/api";
import type {
RuntimeProfile,
RuntimeProtocolFamily,
} from "@multica/core/types";
import {
runtimeProfileListOptions,
useCreateRuntimeProfile,
useUpdateRuntimeProfile,
} from "@multica/core/runtimes";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { cn } from "@multica/ui/lib/utils";
import { ProviderLogo } from "./provider-logo";
import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog";
import {
PROTOCOL_FAMILIES,
buildRuntimeCatalog,
validateProfileForm,
type ProfileFormErrorField,
type ProfileFormValues,
type RuntimeCatalogEntry,
} from "./runtime-profile-catalog";
import { useT } from "../../i18n";
// The dialog runs in two surfaces that swap inside one Popup:
// - "browse": master list (built-in + custom, badged) + adaptive detail
// - "form": create (2-step) or edit (single step, family locked)
type DialogState =
| { surface: "browse" }
| { surface: "form"; mode: "create"; step: "family" | "details" }
| { surface: "form"; mode: "edit"; profile: RuntimeProfile };
export function RuntimeProfilesDialog({
wsId,
onClose,
}: {
wsId: string;
onClose: () => void;
}) {
const { t } = useT("runtimes");
const { data: profiles = [], isLoading } = useQuery(
runtimeProfileListOptions(wsId),
);
const [state, setState] = useState<DialogState>({ surface: "browse" });
const [selectedId, setSelectedId] = useState<string | null>(null);
// Carries the chosen family from create-step-1 into the form.
const [draftFamily, setDraftFamily] =
useState<RuntimeProtocolFamily>(PROTOCOL_FAMILIES[0] ?? "claude");
const catalog = useMemo(() => buildRuntimeCatalog(profiles), [profiles]);
const selectedEntry =
catalog.find((entry) => entry.id === selectedId) ?? null;
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle className="flex items-center gap-2 text-base">
<Server className="h-4 w-4 text-muted-foreground" />
{t(($) => $.profiles.dialog_title)}
</DialogTitle>
<DialogDescription className="text-xs">
{t(($) => $.profiles.dialog_description)}
</DialogDescription>
</DialogHeader>
{state.surface === "form" ? (
<ProfileFormView
wsId={wsId}
mode={state.mode}
step={state.mode === "create" ? state.step : "details"}
family={
state.mode === "edit" ? state.profile.protocol_family : draftFamily
}
profile={state.mode === "edit" ? state.profile : null}
onPickFamily={(family) => {
setDraftFamily(family);
setState({ surface: "form", mode: "create", step: "details" });
}}
onBack={() => {
if (state.mode === "create" && state.step === "details") {
setState({ surface: "form", mode: "create", step: "family" });
} else {
setState({ surface: "browse" });
}
}}
onCancel={() => setState({ surface: "browse" })}
onSaved={(profile) => {
setSelectedId(profile.id);
setState({ surface: "browse" });
}}
/>
) : (
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<CatalogList
entries={catalog}
loading={isLoading}
selectedId={selectedId}
onSelect={setSelectedId}
onAddNew={() =>
setState({ surface: "form", mode: "create", step: "family" })
}
/>
<DetailPanel
entry={selectedEntry}
wsId={wsId}
onEdit={(profile) =>
setState({ surface: "form", mode: "edit", profile })
}
onDeleted={() => setSelectedId(null)}
/>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Master list — built-in families + custom profiles, mixed, each badged.
// ---------------------------------------------------------------------------
function CatalogList({
entries,
loading,
selectedId,
onSelect,
onAddNew,
}: {
entries: RuntimeCatalogEntry[];
loading: boolean;
selectedId: string | null;
onSelect: (id: string) => void;
onAddNew: () => void;
}) {
const { t } = useT("runtimes");
const hasCustom = entries.some((entry) => entry.kind === "custom");
return (
<div className="flex min-h-0 flex-col border-b md:border-b-0 md:border-r">
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5">
<h3 className="text-sm font-medium">
{t(($) => $.profiles.list_title)}
</h3>
<Button type="button" size="sm" className="h-7 px-2" onClick={onAddNew}>
<Plus className="h-3.5 w-3.5" />
{t(($) => $.profiles.add_new)}
</Button>
</div>
{loading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<ul className="min-h-0 flex-1 overflow-y-auto py-1" role="listbox" aria-label={t(($) => $.profiles.list_title)}>
{entries.map((entry) => (
<li key={entry.id}>
<CatalogRow
entry={entry}
active={entry.id === selectedId}
onClick={() => onSelect(entry.id)}
/>
</li>
))}
{!hasCustom && (
<li className="px-4 py-3 text-xs text-muted-foreground">
{t(($) => $.profiles.empty_custom)}
</li>
)}
</ul>
)}
</div>
);
}
function CatalogRow({
entry,
active,
onClick,
}: {
entry: RuntimeCatalogEntry;
active: boolean;
onClick: () => void;
}) {
const { t } = useT("runtimes");
const label =
entry.kind === "custom" ? entry.profile.display_name : entry.protocolFamily;
const disabled = entry.kind === "custom" && !entry.profile.enabled;
return (
<button
type="button"
role="option"
aria-selected={active}
onClick={onClick}
className={cn(
"flex w-full min-w-0 items-center gap-2.5 px-4 py-2 text-left transition-colors",
active ? "bg-accent" : "hover:bg-accent/50",
)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
<ProviderLogo provider={entry.protocolFamily} className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span
className={cn(
"truncate text-sm font-medium",
entry.kind === "builtin" && "capitalize",
)}
>
{label}
</span>
{disabled && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
{t(($) => $.profiles.badge_disabled)}
</span>
)}
</span>
{entry.kind === "custom" && (
<span className="block truncate text-xs capitalize text-muted-foreground">
{entry.protocolFamily}
</span>
)}
</span>
<KindBadge kind={entry.kind} />
</button>
);
}
function KindBadge({ kind }: { kind: "builtin" | "custom" }) {
const { t } = useT("runtimes");
return (
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium",
kind === "custom"
? "bg-info/10 text-info"
: "bg-muted text-muted-foreground",
)}
>
{kind === "custom"
? t(($) => $.profiles.badge_custom)
: t(($) => $.profiles.badge_builtin)}
</span>
);
}
// ---------------------------------------------------------------------------
// Detail panel — adaptive: built-in is read-only, custom shows fields +
// Edit / Delete.
// ---------------------------------------------------------------------------
function DetailPanel({
entry,
wsId,
onEdit,
onDeleted,
}: {
entry: RuntimeCatalogEntry | null;
wsId: string;
onEdit: (profile: RuntimeProfile) => void;
onDeleted: () => void;
}) {
const { t } = useT("runtimes");
const [deleteOpen, setDeleteOpen] = useState(false);
if (!entry) {
return (
<div className="flex min-h-[12rem] flex-1 items-center justify-center p-6 text-center">
<p className="text-sm text-muted-foreground">
{t(($) => $.profiles.detail.select_hint)}
</p>
</div>
);
}
if (entry.kind === "builtin") {
return (
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-md border bg-background">
<ProviderLogo provider={entry.protocolFamily} className="h-5 w-5" />
</span>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold capitalize">
{entry.protocolFamily}
</h3>
<span className="text-xs text-muted-foreground">
{t(($) => $.profiles.builtin_detail.read_only)}
</span>
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">
{t(($) => $.profiles.builtin_detail.description, {
family: entry.protocolFamily,
})}
</p>
</div>
);
}
const profile = entry.profile;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-md border bg-background">
<ProviderLogo
provider={profile.protocol_family}
className="h-5 w-5"
/>
</span>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold">
{profile.display_name}
</h3>
<span className="text-xs capitalize text-muted-foreground">
{profile.protocol_family}
</span>
</div>
</div>
</div>
<dl className="mt-5 space-y-4">
<DetailRow label={t(($) => $.profiles.detail.base_family)}>
<span className="capitalize">{profile.protocol_family}</span>
</DetailRow>
<DetailRow label={t(($) => $.profiles.detail.command)}>
<span className="font-mono text-xs">{profile.command_name}</span>
</DetailRow>
<DetailRow label={t(($) => $.profiles.detail.description)}>
{profile.description ? (
<span>{profile.description}</span>
) : (
<span className="text-muted-foreground">
{t(($) => $.profiles.detail.no_description)}
</span>
)}
</DetailRow>
</dl>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t bg-muted/30 px-6 py-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onEdit(profile)}
>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.profiles.detail.edit)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.profiles.detail.delete)}
</Button>
</div>
<DeleteRuntimeProfileDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
profile={profile}
wsId={wsId}
onDeleted={() => {
setDeleteOpen(false);
onDeleted();
}}
/>
</div>
);
}
function DetailRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</dt>
<dd className="mt-1 text-sm">{children}</dd>
</div>
);
}
// ---------------------------------------------------------------------------
// Create / edit form.
// ---------------------------------------------------------------------------
function ProfileFormView({
wsId,
mode,
step,
family,
profile,
onPickFamily,
onBack,
onCancel,
onSaved,
}: {
wsId: string;
mode: "create" | "edit";
step: "family" | "details";
family: RuntimeProtocolFamily;
profile: RuntimeProfile | null;
onPickFamily: (family: RuntimeProtocolFamily) => void;
onBack: () => void;
onCancel: () => void;
onSaved: (profile: RuntimeProfile) => void;
}) {
const { t } = useT("runtimes");
if (mode === "create" && step === "family") {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<h3 className="text-sm font-medium">
{t(($) => $.profiles.form.step_family_label)}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.profiles.form.step_family_hint)}
</p>
<div
className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3"
role="radiogroup"
aria-label={t(($) => $.profiles.form.family_label)}
>
{PROTOCOL_FAMILIES.map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={option === family}
onClick={() => onPickFamily(option)}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ProviderLogo provider={option} className="h-4 w-4 shrink-0" />
<span className="truncate capitalize">{option}</span>
</button>
))}
</div>
</div>
<div className="flex shrink-0 justify-between gap-2 border-t bg-muted/30 px-6 py-3">
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ChevronLeft className="h-3.5 w-3.5" />
{t(($) => $.profiles.form.back)}
</Button>
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
{t(($) => $.profiles.form.cancel)}
</Button>
</div>
</div>
);
}
return (
<ProfileDetailsForm
wsId={wsId}
mode={mode}
family={family}
profile={profile}
onBack={onBack}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function ProfileDetailsForm({
wsId,
mode,
family,
profile,
onBack,
onCancel,
onSaved,
}: {
wsId: string;
mode: "create" | "edit";
family: RuntimeProtocolFamily;
profile: RuntimeProfile | null;
onBack: () => void;
onCancel: () => void;
onSaved: (profile: RuntimeProfile) => void;
}) {
const { t } = useT("runtimes");
const idPrefix = `runtime-profile-${useId().replace(/:/g, "")}`;
const createProfile = useCreateRuntimeProfile(wsId);
const updateProfile = useUpdateRuntimeProfile(wsId);
const [values, setValues] = useState<ProfileFormValues>({
displayName: profile?.display_name ?? "",
commandName: profile?.command_name ?? "",
description: profile?.description ?? "",
});
const [errors, setErrors] = useState<ProfileFormErrorField[]>([]);
// Server-side error surfaced under the display-name field (duplicate) or
// as a generic banner.
const [duplicateName, setDuplicateName] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const submitting = createProfile.isPending || updateProfile.isPending;
const setField = (key: keyof ProfileFormValues, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setFormError(null);
setDuplicateName(false);
const validationErrors = validateProfileForm(values);
setErrors(validationErrors);
if (validationErrors.length > 0) return;
const description = values.description.trim();
try {
if (mode === "create") {
const created = await createProfile.mutateAsync({
display_name: values.displayName.trim(),
protocol_family: family,
command_name: values.commandName.trim(),
...(description ? { description } : {}),
});
toast.success(t(($) => $.profiles.form.toast_created));
onSaved(created);
} else if (profile) {
const updated = await updateProfile.mutateAsync({
profileId: profile.id,
patch: {
display_name: values.displayName.trim(),
command_name: values.commandName.trim(),
description: description ? description : null,
},
});
toast.success(t(($) => $.profiles.form.toast_updated));
onSaved(updated);
}
} catch (err) {
// 409 from create/patch means the display name collides.
if (err instanceof ApiError && err.status === 409) {
setDuplicateName(true);
return;
}
setFormError(
err instanceof Error && err.message
? err.message
: t(($) => $.profiles.form.error_generic),
);
}
};
const formId = `${idPrefix}-form`;
const hasError = (field: ProfileFormErrorField) => errors.includes(field);
return (
<div className="flex min-h-0 flex-1 flex-col">
<form
id={formId}
onSubmit={handleSubmit}
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5"
>
<h3 className="text-sm font-medium">
{mode === "create"
? t(($) => $.profiles.form.step_details_label)
: t(($) => $.profiles.form.edit_title)}
</h3>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
{t(($) => $.profiles.form.family_label)}
</Label>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2">
<ProviderLogo provider={family} className="h-4 w-4 shrink-0" />
<span className="text-sm capitalize">{family}</span>
</div>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.profiles.form.family_locked_hint)}
</p>
</div>
<div className="space-y-1.5">
<Label
htmlFor={`${idPrefix}-display-name`}
className="text-xs text-muted-foreground"
>
{t(($) => $.profiles.form.display_name_label)}
</Label>
<Input
id={`${idPrefix}-display-name`}
value={values.displayName}
onChange={(e) => setField("displayName", e.target.value)}
placeholder={t(($) => $.profiles.form.display_name_placeholder)}
aria-invalid={hasError("displayName") || duplicateName}
aria-describedby={
hasError("displayName") || duplicateName
? `${idPrefix}-display-name-error`
: undefined
}
className="h-9 text-sm"
/>
{hasError("displayName") && (
<p
id={`${idPrefix}-display-name-error`}
className="text-xs text-destructive"
>
{t(($) => $.profiles.form.error_display_name_required)}
</p>
)}
{duplicateName && !hasError("displayName") && (
<p
id={`${idPrefix}-display-name-error`}
className="text-xs text-destructive"
>
{t(($) => $.profiles.form.error_duplicate_name)}
</p>
)}
</div>
<div className="space-y-1.5">
<Label
htmlFor={`${idPrefix}-command`}
className="text-xs text-muted-foreground"
>
{t(($) => $.profiles.form.command_name_label)}
</Label>
<Input
id={`${idPrefix}-command`}
value={values.commandName}
onChange={(e) => setField("commandName", e.target.value)}
placeholder={t(($) => $.profiles.form.command_name_placeholder)}
aria-invalid={hasError("commandName")}
aria-describedby={
hasError("commandName") ? `${idPrefix}-command-error` : undefined
}
className="h-9 font-mono text-sm"
/>
{hasError("commandName") && (
<p id={`${idPrefix}-command-error`} className="text-xs text-destructive">
{t(($) => $.profiles.form.error_command_required)}
</p>
)}
</div>
<div className="space-y-1.5">
<Label
htmlFor={`${idPrefix}-description`}
className="text-xs text-muted-foreground"
>
{t(($) => $.profiles.form.description_label)}
</Label>
<Textarea
id={`${idPrefix}-description`}
value={values.description}
onChange={(e) => setField("description", e.target.value)}
placeholder={t(($) => $.profiles.form.description_placeholder)}
className="min-h-16 text-sm"
/>
</div>
{/* NOTE: a `fixed_args` input is intentionally omitted in v1 — the
daemon does not yet pass these args to the agent launch command, so
exposing the field would promise admins a no-op. Re-add only once
it's wired end-to-end. See TODO(MUL-3284) in
server/internal/daemon/daemon.go. */}
{/* NOTE: a visibility control is intentionally omitted in v1. The
server forces every profile to 'workspace' because the read paths
(list, daemon pull, register) do not yet enforce 'private', so
offering a private toggle would leak the profile to other members.
Re-add once creator-visibility filtering exists. Follow-up:
MUL-3308. */}
{formError && (
<p role="alert" className="text-xs text-destructive">
{formError}
</p>
)}
</form>
<div className="flex shrink-0 justify-between gap-2 border-t bg-muted/30 px-6 py-3">
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ChevronLeft className="h-3.5 w-3.5" />
{t(($) => $.profiles.form.back)}
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
{t(($) => $.profiles.form.cancel)}
</Button>
<Button type="submit" size="sm" form={formId} disabled={submitting}>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{mode === "create"
? submitting
? t(($) => $.profiles.form.creating)
: t(($) => $.profiles.form.create)
: submitting
? t(($) => $.profiles.form.saving)
: t(($) => $.profiles.form.save)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { runtimeListOptions, runtimeKeys } from "@multica/core/runtimes/queries"
import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks";
import { useWSEvent } from "@multica/core/realtime";
import { agentListOptions } from "@multica/core/workspace/queries";
import { memberListOptions } from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import {
@@ -30,6 +31,7 @@ import { cn } from "@multica/ui/lib/utils";
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { CloudRuntimeDialog } from "./cloud-runtime-dialog";
import { RuntimeProfilesDialog } from "./runtime-profiles-dialog";
import { ProviderLogo } from "./provider-logo";
import { RuntimeList, buildWorkloadIndex } from "./runtime-list";
import {
@@ -119,6 +121,16 @@ export function RuntimesPage({
);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
// Custom runtime management is an admin-only affordance, gated the same
// way the runtime list gates delete: workspace owner/admin role.
const currentMember = currentUserId
? members.find((m) => m.user_id === currentUserId)
: null;
const canManageProfiles =
currentMember?.role === "owner" || currentMember?.role === "admin";
const [showProfilesDialog, setShowProfilesDialog] = useState(false);
const handleDaemonEvent = useCallback(() => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
@@ -197,6 +209,8 @@ export function RuntimesPage({
onConnectRemote={() => setShowConnectDialog(true)}
cloudRuntimeEnabled={cloudRuntimeEnabled}
onOpenCloudRuntime={() => setShowCloudRuntimeDialog(true)}
canManageProfiles={canManageProfiles}
onAddRuntime={() => setShowProfilesDialog(true)}
/>
{showEmpty ? (
@@ -276,6 +290,12 @@ export function RuntimesPage({
{cloudRuntimeEnabled && showCloudRuntimeDialog && (
<CloudRuntimeDialog onClose={() => setShowCloudRuntimeDialog(false)} />
)}
{canManageProfiles && showProfilesDialog && (
<RuntimeProfilesDialog
wsId={wsId}
onClose={() => setShowProfilesDialog(false)}
/>
)}
</div>
);
}
@@ -290,11 +310,15 @@ function PageHeaderBar({
onConnectRemote,
cloudRuntimeEnabled,
onOpenCloudRuntime,
canManageProfiles,
onAddRuntime,
}: {
totalCount: number;
onConnectRemote: () => void;
cloudRuntimeEnabled: boolean;
onOpenCloudRuntime: () => void;
canManageProfiles: boolean;
onAddRuntime: () => void;
}) {
const { t } = useT("runtimes");
return (
@@ -309,6 +333,17 @@ function PageHeaderBar({
)}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{canManageProfiles && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onAddRuntime}
>
<Plus className="h-3.5 w-3.5" />
{t(($) => $.profiles.cta)}
</Button>
)}
{cloudRuntimeEnabled && (
<Button
type="button"

View File

@@ -0,0 +1,368 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/pkg/agent"
)
// ---------------------------------------------------------------------------
// `multica runtime profile ...` — custom runtime profiles (MUL-3284)
//
// A runtime profile lets a workspace declare a custom agent runtime built on
// top of a supported protocol family (the routing backend) but launched via a
// site-specific command_name (e.g. a wrapper that injects credentials). The
// profile lives server-side and is workspace-scoped; the daemon resolves the
// command_name on each host's PATH at registration time.
//
// `set-path` / `unset-path` are the per-machine escape hatch: they record a
// profile_id -> absolute executable path mapping in this machine's local CLI
// config so the daemon can launch a profile whose command isn't on PATH (or
// pick a specific install among several). That mapping never leaves the
// machine — it is not sent to the server.
// ---------------------------------------------------------------------------
var runtimeProfileCmd = &cobra.Command{
Use: "profile",
Short: "Manage custom runtime profiles",
}
var runtimeProfileListCmd = &cobra.Command{
Use: "list",
Short: "List custom runtime profiles in the workspace",
RunE: runRuntimeProfileList,
}
var runtimeProfileCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a custom runtime profile",
RunE: runRuntimeProfileCreate,
}
var runtimeProfileUpdateCmd = &cobra.Command{
Use: "update <profile-id>",
Short: "Update a custom runtime profile (protocol family is immutable)",
Args: exactArgs(1),
RunE: runRuntimeProfileUpdate,
}
var runtimeProfileDeleteCmd = &cobra.Command{
Use: "delete <profile-id>",
Short: "Delete a custom runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileDelete,
}
var runtimeProfileSetPathCmd = &cobra.Command{
Use: "set-path <profile-id>",
Short: "Pin a per-machine executable path for a runtime profile (local only)",
Args: exactArgs(1),
RunE: runRuntimeProfileSetPath,
}
var runtimeProfileUnsetPathCmd = &cobra.Command{
Use: "unset-path <profile-id>",
Short: "Remove a per-machine executable path override for a runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileUnsetPath,
}
func init() {
runtimeCmd.AddCommand(runtimeProfileCmd)
runtimeProfileCmd.AddCommand(runtimeProfileListCmd)
runtimeProfileCmd.AddCommand(runtimeProfileCreateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUpdateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileDeleteCmd)
runtimeProfileCmd.AddCommand(runtimeProfileSetPathCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUnsetPathCmd)
// list
runtimeProfileListCmd.Flags().String("output", "table", "Output format: table or json")
// create
runtimeProfileCreateCmd.Flags().String("protocol-family", "", "Supported backend the profile routes to (required)")
runtimeProfileCreateCmd.Flags().String("command-name", "", "Executable the daemon resolves on PATH (required)")
runtimeProfileCreateCmd.Flags().String("display-name", "", "Human-readable profile name (required)")
runtimeProfileCreateCmd.Flags().String("description", "", "Optional description")
runtimeProfileCreateCmd.Flags().String("output", "json", "Output format: table or json")
// update
runtimeProfileUpdateCmd.Flags().String("display-name", "", "New display name")
runtimeProfileUpdateCmd.Flags().String("command-name", "", "New command name")
runtimeProfileUpdateCmd.Flags().String("description", "", "New description")
// NOTE: a --fixed-arg flag is intentionally NOT exposed in v1. The server
// carries the fixed_args column, but the daemon does not yet pass these
// args to the agent launch command, so a CLI flag would promise admins a
// no-op. Re-add once it's wired end-to-end (TODO(MUL-3284), see
// server/internal/daemon/daemon.go).
runtimeProfileUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the profile")
runtimeProfileUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// set-path
runtimeProfileSetPathCmd.Flags().String("path", "", "Absolute path to the executable on this machine (required)")
}
// runtimeProfilesPath builds the workspace-scoped collection path.
func runtimeProfilesPath(workspaceID string) string {
return fmt.Sprintf("/api/workspaces/%s/runtime-profiles", workspaceID)
}
// validateProtocolFamily checks a protocol family against the canonical agent
// whitelist client-side so an obvious typo fails fast with a helpful list
// instead of an opaque server 400.
func validateProtocolFamily(family string) error {
if !agent.IsSupportedType(family) {
return fmt.Errorf("invalid --protocol-family %q: must be one of %s",
family, strings.Join(agent.SupportedTypes, ", "))
}
return nil
}
// NOTE: a --visibility flag is intentionally NOT exposed in v1. The server
// forces every profile to 'workspace' because the read paths do not yet
// enforce 'private' (exposing it would leak "private" profiles). Re-add once
// creator-visibility filtering exists. Follow-up: MUL-3308.
func runRuntimeProfileList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var resp struct {
RuntimeProfiles []map[string]any `json:"runtime_profiles"`
}
if err := client.GetJSON(ctx, runtimeProfilesPath(workspaceID), &resp); err != nil {
return fmt.Errorf("list runtime profiles: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp.RuntimeProfiles)
}
printRuntimeProfileTable(resp.RuntimeProfiles)
return nil
}
func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error {
family, _ := cmd.Flags().GetString("protocol-family")
commandName, _ := cmd.Flags().GetString("command-name")
displayName, _ := cmd.Flags().GetString("display-name")
description, _ := cmd.Flags().GetString("description")
if strings.TrimSpace(family) == "" {
return fmt.Errorf("--protocol-family is required")
}
if strings.TrimSpace(commandName) == "" {
return fmt.Errorf("--command-name is required")
}
if strings.TrimSpace(displayName) == "" {
return fmt.Errorf("--display-name is required")
}
if err := validateProtocolFamily(family); err != nil {
return err
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
body := map[string]any{
"display_name": displayName,
"protocol_family": family,
"command_name": commandName,
}
if description != "" {
body["description"] = description
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var profile map[string]any
if err := client.PostJSON(ctx, runtimeProfilesPath(workspaceID), body, &profile); err != nil {
return fmt.Errorf("create runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileUpdate(cmd *cobra.Command, args []string) error {
profileID := args[0]
body := map[string]any{}
if cmd.Flags().Changed("display-name") {
v, _ := cmd.Flags().GetString("display-name")
body["display_name"] = v
}
if cmd.Flags().Changed("command-name") {
v, _ := cmd.Flags().GetString("command-name")
body["command_name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("enabled") {
v, _ := cmd.Flags().GetBool("enabled")
body["enabled"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update: pass at least one of --display-name, --command-name, --description, --enabled")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
var profile map[string]any
if err := client.PatchJSON(ctx, path, body, &profile); err != nil {
return fmt.Errorf("update runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileDelete(cmd *cobra.Command, args []string) error {
profileID := args[0]
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
if err := client.DeleteJSON(ctx, path); err != nil {
// 409 means the server refused because active agents are still bound
// to this profile. Surface the server's explanation verbatim rather
// than the generic HTTP wrapper so the user sees what to unbind.
var httpErr *cli.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusConflict {
msg := strings.TrimSpace(httpErr.Body)
if msg == "" {
msg = "profile still has active agents bound to it"
}
return fmt.Errorf("cannot delete runtime profile %s: %s", profileID, msg)
}
return fmt.Errorf("delete runtime profile: %w", err)
}
fmt.Printf("Deleted runtime profile %s\n", profileID)
return nil
}
func runRuntimeProfileSetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
path, _ := cmd.Flags().GetString("path")
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("--path is required")
}
if !filepath.IsAbs(path) {
return fmt.Errorf("--path must be an absolute path, got %q", path)
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if cfg.ProfileCommandOverrides == nil {
cfg.ProfileCommandOverrides = map[string]string{}
}
cfg.ProfileCommandOverrides[profileID] = path
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Pinned runtime profile %s to %s on this machine.\n", profileID, path)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
func runRuntimeProfileUnsetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if _, ok := cfg.ProfileCommandOverrides[profileID]; !ok {
fmt.Printf("No per-machine path override set for runtime profile %s.\n", profileID)
return nil
}
delete(cfg.ProfileCommandOverrides, profileID)
if len(cfg.ProfileCommandOverrides) == 0 {
// Normalize back to nil so the key drops out of the saved JSON.
cfg.ProfileCommandOverrides = nil
}
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Removed per-machine path override for runtime profile %s.\n", profileID)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
// outputRuntimeProfile renders a single profile honoring --output.
func outputRuntimeProfile(cmd *cobra.Command, profile map[string]any) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, profile)
}
printRuntimeProfileTable([]map[string]any{profile})
return nil
}
// printRuntimeProfileTable renders profiles as a stable, sorted table.
func printRuntimeProfileTable(profiles []map[string]any) {
headers := []string{"ID", "DISPLAY_NAME", "PROTOCOL_FAMILY", "COMMAND_NAME", "ENABLED"}
rows := make([][]string, 0, len(profiles))
for _, p := range profiles {
rows = append(rows, []string{
strVal(p, "id"),
strVal(p, "display_name"),
strVal(p, "protocol_family"),
strVal(p, "command_name"),
strVal(p, "enabled"),
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i][1] < rows[j][1] })
cli.PrintTable(os.Stdout, headers, rows)
}

View File

@@ -0,0 +1,360 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// addCommonProfileFlags wires the persistent-style flags the run functions
// resolve (server-url, workspace-id, profile, token) onto a detached test
// command so the helpers can be invoked directly.
func addCommonProfileFlags(cmd *cobra.Command) {
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("token", "", "")
}
func newProfileListTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "list"}
addCommonProfileFlags(cmd)
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileCreateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "create"}
addCommonProfileFlags(cmd)
cmd.Flags().String("protocol-family", "", "")
cmd.Flags().String("command-name", "", "")
cmd.Flags().String("display-name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileUpdateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "update"}
addCommonProfileFlags(cmd)
cmd.Flags().String("display-name", "", "")
cmd.Flags().String("command-name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().Bool("enabled", true, "")
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileDeleteTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "delete"}
addCommonProfileFlags(cmd)
return cmd
}
func newProfileSetPathTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set-path"}
addCommonProfileFlags(cmd)
cmd.Flags().String("path", "", "")
return cmd
}
func newProfileUnsetPathTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "unset-path"}
addCommonProfileFlags(cmd)
return cmd
}
// TestRuntimeProfileCommandsRegistered verifies the subcommands are wired
// under `runtime profile`.
func TestRuntimeProfileCommandsRegistered(t *testing.T) {
for _, name := range []string{"list", "create", "update", "delete", "set-path", "unset-path"} {
cmd, _, err := runtimeProfileCmd.Find([]string{name})
if err != nil {
t.Fatalf("find %q: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("%q not registered under `runtime profile`; got %#v", name, cmd)
}
}
// And `profile` itself must hang off `runtime`.
cmd, _, err := runtimeCmd.Find([]string{"profile", "list"})
if err != nil || cmd == nil || cmd.Name() != "list" {
t.Fatalf("`runtime profile list` not reachable from runtime command: %v / %#v", err, cmd)
}
}
func TestRunRuntimeProfileList(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"runtime_profiles": []map[string]any{
{"id": "prof-1", "display_name": "Company Codex", "protocol_family": "codex", "command_name": "company-codex", "visibility": "workspace", "enabled": true},
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileListTestCmd()
_ = cmd.Flags().Set("output", "json")
if err := runRuntimeProfileList(cmd, nil); err != nil {
t.Fatalf("runRuntimeProfileList: %v", err)
}
if gotMethod != http.MethodGet {
t.Errorf("method = %s, want GET", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles" {
t.Errorf("path = %q, want /api/workspaces/ws-123/runtime-profiles", gotPath)
}
}
func TestRunRuntimeProfileCreate(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": "prof-1", "display_name": "Company Codex"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileCreateTestCmd()
_ = cmd.Flags().Set("protocol-family", "codex")
_ = cmd.Flags().Set("command-name", "company-codex")
_ = cmd.Flags().Set("display-name", "Company Codex")
if err := runRuntimeProfileCreate(cmd, nil); err != nil {
t.Fatalf("runRuntimeProfileCreate: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %s, want POST", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles" {
t.Errorf("path = %q, want /api/workspaces/ws-123/runtime-profiles", gotPath)
}
if gotBody["protocol_family"] != "codex" || gotBody["command_name"] != "company-codex" || gotBody["display_name"] != "Company Codex" {
t.Errorf("unexpected body: %#v", gotBody)
}
// fixed_args is intentionally NOT exposed by the CLI in v1 (the daemon does
// not yet wire it into the launch command), so it must never be sent.
if _, present := gotBody["fixed_args"]; present {
t.Errorf("fixed_args must not be sent by the CLI, got %#v", gotBody["fixed_args"])
}
// visibility is intentionally NOT exposed by the CLI in v1 (server forces
// 'workspace'), so it must never be sent.
if _, present := gotBody["visibility"]; present {
t.Errorf("visibility must not be sent by the CLI, got %#v", gotBody["visibility"])
}
}
func TestRunRuntimeProfileCreateRejectsBadFamily(t *testing.T) {
cmd := newProfileCreateTestCmd()
_ = cmd.Flags().Set("protocol-family", "not-a-real-backend")
_ = cmd.Flags().Set("command-name", "x")
_ = cmd.Flags().Set("display-name", "X")
// No server should ever be contacted; this must fail client-side.
if err := runRuntimeProfileCreate(cmd, nil); err == nil {
t.Fatal("expected invalid --protocol-family error")
}
}
func TestRunRuntimeProfileCreateRequiresFlags(t *testing.T) {
cmd := newProfileCreateTestCmd()
if err := runRuntimeProfileCreate(cmd, nil); err == nil {
t.Fatal("expected missing --protocol-family error")
}
}
func TestRunRuntimeProfileUpdateOnlySendsChangedFlags(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "prof-1"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileUpdateTestCmd()
_ = cmd.Flags().Set("command-name", "new-codex")
_ = cmd.Flags().Set("enabled", "false")
if err := runRuntimeProfileUpdate(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileUpdate: %v", err)
}
if gotMethod != http.MethodPatch {
t.Errorf("method = %s, want PATCH", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles/prof-1" {
t.Errorf("path = %q, want .../runtime-profiles/prof-1", gotPath)
}
// Only the two changed flags must be present.
if gotBody["command_name"] != "new-codex" {
t.Errorf("command_name = %v, want new-codex", gotBody["command_name"])
}
if gotBody["enabled"] != false {
t.Errorf("enabled = %v, want false", gotBody["enabled"])
}
if _, ok := gotBody["display_name"]; ok {
t.Errorf("display_name should not be sent when unchanged: %#v", gotBody)
}
if _, ok := gotBody["visibility"]; ok {
t.Errorf("visibility should not be sent when unchanged: %#v", gotBody)
}
}
func TestRunRuntimeProfileUpdateNoFieldsErrors(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
cmd := newProfileUpdateTestCmd()
if err := runRuntimeProfileUpdate(cmd, []string{"prof-1"}); err == nil {
t.Fatal("expected 'no fields to update' error")
}
}
func TestRunRuntimeProfileDeleteSuccess(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileDeleteTestCmd()
if err := runRuntimeProfileDelete(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileDelete: %v", err)
}
if gotMethod != http.MethodDelete {
t.Errorf("method = %s, want DELETE", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles/prof-1" {
t.Errorf("path = %q, want .../runtime-profiles/prof-1", gotPath)
}
}
func TestRunRuntimeProfileDeleteConflictSurfacesServerMessage(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte("2 active agents are bound to this profile"))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileDeleteTestCmd()
err := runRuntimeProfileDelete(cmd, []string{"prof-1"})
if err == nil {
t.Fatal("expected conflict error")
}
if got := err.Error(); !strings.Contains(got, "2 active agents are bound to this profile") {
t.Errorf("error %q should surface the server message", got)
}
}
func TestRunRuntimeProfileSetAndUnsetPath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
// set-path
setCmd := newProfileSetPathTestCmd()
_ = setCmd.Flags().Set("path", "/opt/bin/company-codex")
if err := runRuntimeProfileSetPath(setCmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileSetPath: %v", err)
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig: %v", err)
}
if got := cfg.ProfileCommandOverrides["prof-1"]; got != "/opt/bin/company-codex" {
t.Fatalf("override after set = %q, want /opt/bin/company-codex", got)
}
// unset-path
unsetCmd := newProfileUnsetPathTestCmd()
if err := runRuntimeProfileUnsetPath(unsetCmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileUnsetPath: %v", err)
}
cfg, err = cli.LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig after unset: %v", err)
}
if _, ok := cfg.ProfileCommandOverrides["prof-1"]; ok {
t.Fatalf("override should be removed after unset, got %#v", cfg.ProfileCommandOverrides)
}
}
func TestRunRuntimeProfileSetPathRejectsRelative(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cmd := newProfileSetPathTestCmd()
_ = cmd.Flags().Set("path", "relative/path")
if err := runRuntimeProfileSetPath(cmd, []string{"prof-1"}); err == nil {
t.Fatal("expected absolute-path error")
}
}
func TestRunRuntimeProfileSetPathPreservesExistingConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Seed an existing config with unrelated fields.
seed := cli.CLIConfig{ServerURL: "https://api.multica.ai", WorkspaceID: "ws-123", Token: "mul_xyz"}
if err := cli.SaveCLIConfig(seed); err != nil {
t.Fatal(err)
}
cmd := newProfileSetPathTestCmd()
_ = cmd.Flags().Set("path", "/opt/bin/company-codex")
if err := runRuntimeProfileSetPath(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileSetPath: %v", err)
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://api.multica.ai" || cfg.WorkspaceID != "ws-123" || cfg.Token != "mul_xyz" {
t.Errorf("set-path clobbered existing config: %#v", cfg)
}
if cfg.ProfileCommandOverrides["prof-1"] != "/opt/bin/company-codex" {
t.Errorf("override not written: %#v", cfg.ProfileCommandOverrides)
}
}

View File

@@ -497,6 +497,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/heartbeat", h.DaemonHeartbeat)
r.Get("/ws", h.DaemonWebSocket)
r.Get("/workspaces/{workspaceId}/repos", h.GetDaemonWorkspaceRepos)
r.Get("/workspaces/{workspaceId}/runtime-profiles", h.DaemonListRuntimeProfiles)
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
@@ -575,6 +576,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// the handler strips the management handle and adds a
// can_manage hint so the UI can gate connect/disconnect.
r.Get("/github/installations", h.ListGitHubInstallations)
// Custom runtime profiles — listing/reading is member-visible
// (the Runtime page renders for everyone; create/edit/delete
// are admin-gated below).
r.Get("/runtime-profiles", h.ListRuntimeProfiles)
r.Get("/runtime-profiles/{profileId}", h.GetRuntimeProfile)
})
// Admin-level access
r.Group(func(r chi.Router) {
@@ -587,6 +593,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Delete("/", h.DeleteMember)
})
r.Delete("/invitations/{invitationId}", h.RevokeInvitation)
// Custom runtime profile mutations (admin-only).
r.Post("/runtime-profiles", h.CreateRuntimeProfile)
r.Patch("/runtime-profiles/{profileId}", h.UpdateRuntimeProfile)
r.Put("/runtime-profiles/{profileId}", h.UpdateRuntimeProfile)
r.Delete("/runtime-profiles/{profileId}", h.DeleteRuntimeProfile)
})
// Owner-only access
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)

View File

@@ -23,6 +23,19 @@ type CLIConfig struct {
// machine). Empty / absent means "discover from PATH and use vendor
// defaults" — the historical behavior. See issue #3875.
Backends *BackendOverrides `json:"backends,omitempty"`
// ProfileCommandOverrides is a per-machine map of custom runtime
// profile_id -> absolute executable path (MUL-3284). A workspace custom
// runtime profile records the command_name the daemon resolves on PATH,
// but the same logical profile may live at a different path on each
// machine (or not be on PATH at all). This map lets an operator pin the
// exact binary for a profile on this host via
// `multica runtime profile set-path`; the daemon prefers it over the
// PATH lookup in appendProfileRuntimes. Empty / absent means "resolve the
// profile's command_name on PATH" — the default behavior. The mapping is
// intentionally local-only (it is never sent to the server) because the
// path is a property of this machine, not of the shared profile.
ProfileCommandOverrides map[string]string `json:"profile_command_overrides,omitempty"`
}
// BackendOverrides holds per-backend configuration overrides. Each field is

View File

@@ -166,6 +166,92 @@ func TestCLIConfig_OpenClawOverride_PartialFieldsOmitted(t *testing.T) {
}
}
// TestCLIConfig_ProfileCommandOverrides_RoundTrip verifies that pinning a
// per-machine profile command path survives a save/load cycle AND that
// unrelated fields (server_url, token, backends) are preserved across the
// round-trip — the set-path / unset-path CLI commands rely on a
// load->modify->save cycle never dropping config the user already had.
func TestCLIConfig_ProfileCommandOverrides_RoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
original := CLIConfig{
ServerURL: "https://api.multica.ai",
AppURL: "https://app.multica.ai",
WorkspaceID: "ws-123",
Token: "mul_xyz",
Backends: &BackendOverrides{
OpenClaw: &OpenClawOverride{StateDir: "/var/lib/openclaw-prod"},
},
ProfileCommandOverrides: map[string]string{
"prof-1": "/opt/bin/company-codex",
"prof-2": "/usr/local/bin/special-claude",
},
}
if err := SaveCLIConfig(original); err != nil {
t.Fatal(err)
}
loaded, err := LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
// The override map must round-trip intact.
if len(loaded.ProfileCommandOverrides) != 2 {
t.Fatalf("ProfileCommandOverrides len = %d, want 2: %+v", len(loaded.ProfileCommandOverrides), loaded.ProfileCommandOverrides)
}
if got := loaded.ProfileCommandOverrides["prof-1"]; got != "/opt/bin/company-codex" {
t.Errorf("prof-1 override = %q, want /opt/bin/company-codex", got)
}
if got := loaded.ProfileCommandOverrides["prof-2"]; got != "/usr/local/bin/special-claude" {
t.Errorf("prof-2 override = %q, want /usr/local/bin/special-claude", got)
}
// Every other field must be preserved (no clobbering on round-trip).
if loaded.ServerURL != original.ServerURL {
t.Errorf("ServerURL = %q, want %q", loaded.ServerURL, original.ServerURL)
}
if loaded.AppURL != original.AppURL {
t.Errorf("AppURL = %q, want %q", loaded.AppURL, original.AppURL)
}
if loaded.WorkspaceID != original.WorkspaceID {
t.Errorf("WorkspaceID = %q, want %q", loaded.WorkspaceID, original.WorkspaceID)
}
if loaded.Token != original.Token {
t.Errorf("Token = %q, want %q", loaded.Token, original.Token)
}
if loaded.Backends == nil || loaded.Backends.OpenClaw == nil ||
loaded.Backends.OpenClaw.StateDir != "/var/lib/openclaw-prod" {
t.Errorf("Backends.OpenClaw not preserved: %+v", loaded.Backends)
}
}
// TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty verifies the
// omitempty tag keeps the key out of the on-disk JSON when no overrides are
// set, so configs for users who never pin a path stay byte-stable.
func TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfg := CLIConfig{ServerURL: "https://api.multica.ai", Token: "mul_xyz"}
if err := SaveCLIConfig(cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
if _, ok := raw["profile_command_overrides"]; ok {
t.Errorf("profile_command_overrides should be omitted when empty, got: %s", string(data))
}
}
// TestCLIConfig_UnknownFieldsArePreserved verifies forward-compat: a future
// daemon that adds, say, a `backends.codex` key should not have its data
// destroyed when an older daemon (without knowledge of that key) reads and

View File

@@ -462,6 +462,44 @@ func (c *Client) GetWorkspaceRepos(ctx context.Context, workspaceID string) (*Wo
return &resp, nil
}
// RuntimeProfile mirrors the server's workspace custom runtime profile
// (MUL-3284). protocol_family is the provider used for task routing (it
// selects the agent backend), while command_name is the actual executable
// the daemon resolves on PATH and launches. fixed_args are launch arguments
// every agent on this runtime inherits — wiring them into the spawned command
// is best-effort and may not be plumbed yet (see the TODO in runTask).
type RuntimeProfile struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
DisplayName string `json:"display_name"`
ProtocolFamily string `json:"protocol_family"`
CommandName string `json:"command_name"`
Description *string `json:"description"`
FixedArgs []string `json:"fixed_args"`
Visibility string `json:"visibility"`
Enabled bool `json:"enabled"`
}
// RuntimeProfilesResponse is the body of
// GET /api/daemon/workspaces/{workspaceID}/runtime-profiles. The server only
// returns enabled profiles for the workspace.
type RuntimeProfilesResponse struct {
WorkspaceID string `json:"workspace_id"`
RuntimeProfiles []RuntimeProfile `json:"runtime_profiles"`
}
// GetRuntimeProfiles fetches the workspace's enabled custom runtime profiles.
// Mirrors GetWorkspaceRepos. Callers must treat this as best-effort: an older
// server with no profiles route returns 404, which the daemon swallows and
// continues with built-in runtimes only.
func (c *Client) GetRuntimeProfiles(ctx context.Context, workspaceID string) (*RuntimeProfilesResponse, error) {
var resp RuntimeProfilesResponse
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/workspaces/%s/runtime-profiles", workspaceID), &resp); err != nil {
return nil, err
}
return &resp, nil
}
// defaultTerminalRetrySchedule is the backoff used by postJSONWithRetry for
// terminal task callbacks (CompleteTask / FailTask). N entries → N+1 attempts
// in the worst case (one immediate + N retries). Five backoffs totalling

View File

@@ -102,6 +102,14 @@ type Config struct {
ClaudeArgs []string
CodexArgs []string
CodebuddyArgs []string
// ProfileCommandOverrides maps a custom runtime profile_id -> the absolute
// executable path to use for that profile on THIS machine (MUL-3284).
// Sourced from the local CLI config (cli.CLIConfig.ProfileCommandOverrides),
// written by `multica runtime profile set-path`. appendProfileRuntimes
// prefers a matching, executable override over resolving the profile's
// command_name on PATH. nil/empty means "always resolve via PATH".
ProfileCommandOverrides map[string]string
}
// Overrides allows CLI flags to override environment variables and defaults.
@@ -165,11 +173,26 @@ func LoadConfig(overrides Overrides) (Config, error) {
// file should not prevent daemon startup, since the daemon can still run
// purely from env-var configuration. We log a warning and proceed with
// no overrides.
var profileCommandOverrides map[string]string
if cliCfg, err := cli.LoadCLIConfigForProfile(overrides.Profile); err != nil {
slog.Warn("could not load CLI config for backend overrides; proceeding without",
"profile", overrides.Profile, "err", err)
} else if oc := openclawOverrideFrom(cliCfg); oc != nil {
applyOpenclawOverride(oc)
} else {
if oc := openclawOverrideFrom(cliCfg); oc != nil {
applyOpenclawOverride(oc)
}
// Per-machine custom-runtime command path overrides (MUL-3284).
// Copy into our own map so later mutation of the loaded config can't
// alias daemon state, and so an empty map normalizes to nil.
if len(cliCfg.ProfileCommandOverrides) > 0 {
profileCommandOverrides = make(map[string]string, len(cliCfg.ProfileCommandOverrides))
for id, path := range cliCfg.ProfileCommandOverrides {
if id == "" || strings.TrimSpace(path) == "" {
continue
}
profileCommandOverrides[id] = path
}
}
}
// Probe available agent CLIs. exec.LookPath is the primary path, but on
@@ -500,6 +523,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
ClaudeArgs: claudeArgs,
CodexArgs: codexArgs,
CodebuddyArgs: codebuddyArgs,
ProfileCommandOverrides: profileCommandOverrides,
}, nil
}

View File

@@ -9,6 +9,7 @@ import (
"log/slog"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -58,6 +59,26 @@ var (
// helpers above.
detectAgentVersion = agent.DetectVersion
checkAgentMinVersion = agent.CheckMinVersion
// lookPath is an indirection over exec.LookPath so registration tests can
// resolve custom runtime-profile commands without manipulating the
// process PATH. Mirrors the detectAgentVersion hook above.
lookPath = exec.LookPath
// profilePathExecutable reports whether path points at an existing,
// non-directory file with at least one executable bit set. It is the
// gate appendProfileRuntimes uses before trusting a per-machine command
// path override (MUL-3284) — a stale or mistyped override must fall back
// to the PATH lookup rather than register a runtime that can't launch.
// Indirected as a package var so tests can assert override preference
// without staging a real executable on disk.
profilePathExecutable = func(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return info.Mode().Perm()&0o111 != 0
}
)
// workspaceState tracks registered runtimes for a single workspace.
@@ -95,6 +116,12 @@ type Daemon struct {
mu sync.Mutex
workspaces map[string]*workspaceState
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
// profileCommandPaths maps a custom runtime profile_id -> the absolute
// executable path resolved on PATH for that profile's command_name
// (MUL-3284). Populated in registerRuntimesForWorkspace when a profile's
// command resolves; read by runTask via customCommandPathForRuntime to
// launch the custom command for a claimed task. Guarded by mu.
profileCommandPaths map[string]string
reloading sync.Mutex // prevents concurrent workspace syncs
runtimeSet *runtimeSetWatcher // multi-subscriber pub/sub for runtime-set changes
@@ -176,6 +203,7 @@ func New(cfg Config, logger *slog.Logger) *Daemon {
logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
profileCommandPaths: make(map[string]string),
runtimeSet: newRuntimeSetWatcher(),
agentVersions: make(map[string]string),
wsHBLastAck: make(map[string]time.Time),
@@ -739,6 +767,45 @@ func (d *Daemon) findRuntime(id string) *Runtime {
return nil
}
// recordProfileCommandPath remembers the absolute executable path resolved
// for a custom runtime profile's command_name. Called from
// registerRuntimesForWorkspace. Lazily initializes the map so test fixtures
// that build a Daemon literal without seeding every map don't panic.
func (d *Daemon) recordProfileCommandPath(profileID, path string) {
if profileID == "" || path == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if d.profileCommandPaths == nil {
d.profileCommandPaths = make(map[string]string)
}
d.profileCommandPaths[profileID] = path
}
// customCommandPathForRuntime returns the resolved custom executable path for
// a claimed task's RuntimeID, and whether the runtime is a custom-profile
// runtime. It returns ("", false) for built-in runtimes (no profile) and for
// runtimes whose profile command was never resolved on this host. runTask
// uses this to override the launch path so a custom runtime can run even when
// the host has no built-in agent of the same provider installed.
func (d *Daemon) customCommandPathForRuntime(runtimeID string) (string, bool) {
if runtimeID == "" {
return "", false
}
d.mu.Lock()
defer d.mu.Unlock()
rt, ok := d.runtimeIndex[runtimeID]
if !ok || rt.ProfileID == "" {
return "", false
}
path, ok := d.profileCommandPaths[rt.ProfileID]
if !ok || path == "" {
return "", false
}
return path, true
}
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, error) {
d.logger.Debug("registering runtimes for workspace", "workspace_id", workspaceID, "agent_count", len(d.cfg.Agents))
var runtimes []map[string]string
@@ -765,6 +832,14 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
"status": "online",
})
}
// Append any workspace custom runtime profiles whose command resolves on
// this host (MUL-3284). This is best-effort: a fetch error (e.g. an older
// server returning 404) must never fail registration — the daemon simply
// continues with the built-in runtimes it already collected. A profile
// whose command_name is not on PATH is skipped (the host doesn't have it).
d.appendProfileRuntimes(ctx, workspaceID, &runtimes)
if len(runtimes) == 0 {
return nil, fmt.Errorf("no agent runtimes could be registered")
}
@@ -790,6 +865,104 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
return resp, nil
}
// appendProfileRuntimes fetches the workspace's enabled custom runtime
// profiles (MUL-3284) and appends a runtime registration entry for each one
// whose command_name resolves on this host's PATH. For each resolved profile
// it records the absolute command path keyed by profile_id (via
// recordProfileCommandPath) so runTask can later launch the custom executable
// for a claimed task.
//
// Best-effort by contract: any error fetching profiles (older server, network
// blip) is logged and swallowed — registration proceeds with the built-in
// runtimes already collected. A profile whose command is not on PATH is
// skipped with an Info log (this host simply doesn't have that command).
//
// The registration entry mirrors the built-in shape: name = display_name
// (suffixed with the device name like the built-in path), type =
// protocol_family (the routing provider), version = best-effort detected
// version, status = "online", plus the profile_id the server validates.
func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string, runtimes *[]map[string]string) {
resp, err := d.client.GetRuntimeProfiles(ctx, workspaceID)
if err != nil {
// Best-effort: never fail registration because profiles couldn't be
// fetched. An older server with no profiles route returns 404.
d.logger.Info("skip custom runtime profiles: fetch failed (continuing with built-in runtimes)",
"workspace_id", workspaceID, "error", err)
return
}
if resp == nil {
return
}
for _, profile := range resp.RuntimeProfiles {
if profile.CommandName == "" || profile.ProtocolFamily == "" {
d.logger.Warn("skip custom runtime profile: missing command_name or protocol_family",
"workspace_id", workspaceID, "profile_id", profile.ID, "display_name", profile.DisplayName)
continue
}
// Resolve the executable to launch for this profile. A per-machine
// path override (MUL-3284, `multica runtime profile set-path`) wins
// over the PATH lookup when it is set AND points at a real
// executable — this is how an operator pins a profile to a binary
// that isn't on the daemon's PATH, or selects between multiple
// installs on the same host. A configured-but-unusable override
// (deleted/moved/non-executable) is logged and falls back to PATH
// rather than registering a runtime that can't launch. When neither
// the override nor PATH resolves, the profile is skipped (existing
// behavior).
var resolved string
if override := strings.TrimSpace(d.cfg.ProfileCommandOverrides[profile.ID]); override != "" {
if profilePathExecutable(override) {
resolved = override
d.logger.Info("custom runtime profile: using per-machine command path override",
"workspace_id", workspaceID, "profile_id", profile.ID, "command_path", resolved)
} else {
d.logger.Warn("custom runtime profile: command path override not executable; falling back to PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"override_path", override, "command_name", profile.CommandName)
}
}
if resolved == "" {
r, err := lookPath(profile.CommandName)
if err != nil {
// Host doesn't have this command — expected on hosts that aren't
// provisioned for this profile. Skip without failing.
d.logger.Info("skip custom runtime profile: command not found on PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"command_name", profile.CommandName, "error", err)
continue
}
resolved = r
}
// Best-effort version detection; an empty version is acceptable.
version, verErr := detectAgentVersion(ctx, resolved)
if verErr != nil {
d.logger.Debug("custom runtime profile: version probe failed (registering with empty version)",
"workspace_id", workspaceID, "profile_id", profile.ID, "path", resolved, "error", verErr)
version = ""
}
displayName := profile.DisplayName
if d.cfg.DeviceName != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, d.cfg.DeviceName)
}
d.recordProfileCommandPath(profile.ID, resolved)
d.logger.Info("registering custom runtime profile",
"workspace_id", workspaceID, "profile_id", profile.ID,
"protocol_family", profile.ProtocolFamily, "command_path", resolved)
// NOTE: profile.FixedArgs are launch args every agent on this runtime
// inherits. Wiring them into the spawned command is intentionally not
// done here — it's an optional, best-effort enhancement (see MUL-3284
// PR2 task notes). TODO(MUL-3284): plumb FixedArgs into the agent
// launch command if/when the agent backend exposes a hook for it.
*runtimes = append(*runtimes, map[string]string{
"name": displayName,
"type": profile.ProtocolFamily,
"version": version,
"status": "online",
"profile_id": profile.ID,
})
}
}
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
return &workspaceState{
workspaceID: workspaceID,
@@ -2630,6 +2803,20 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
d.registerTaskRepos(task.WorkspaceID, task.Repos)
entry, ok := d.cfg.Agents[provider]
// A custom runtime profile (MUL-3284) overrides the executable path: the
// runtime's protocol_family is the provider (so agent.New still selects
// the right backend), but the actual binary on PATH is the profile's
// command_name, resolved at registration time and keyed by RuntimeID here.
// Critically, a custom runtime can live on a host that has NO built-in
// agent of the same provider installed, so when the runtime is custom we
// synthesize an AgentEntry instead of hard-failing on the !ok lookup.
if customPath, isCustom := d.customCommandPathForRuntime(task.RuntimeID); isCustom {
entry.Path = customPath
ok = true
d.logger.Info("task uses custom runtime profile command",
"task_id", task.ID, "runtime_id", task.RuntimeID,
"provider", provider, "command_path", customPath)
}
if !ok {
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
}

View File

@@ -0,0 +1,353 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// stubLookPath swaps the package-level lookPath indirection used by
// registerRuntimesForWorkspace to resolve custom runtime-profile commands,
// so tests don't have to mutate the process PATH. resolved maps a command
// name to the absolute path it should resolve to; an absent name reports
// "not found".
func stubLookPath(t *testing.T, resolved map[string]string) {
t.Helper()
orig := lookPath
lookPath = func(cmd string) (string, error) {
if p, ok := resolved[cmd]; ok {
return p, nil
}
return "", &osExecNotFound{cmd: cmd}
}
t.Cleanup(func() { lookPath = orig })
}
type osExecNotFound struct{ cmd string }
func (e *osExecNotFound) Error() string { return "exec: " + e.cmd + ": not found in $PATH" }
// TestClient_GetRuntimeProfiles_RequestShape asserts the daemon GETs the
// documented path and parses the server's runtime_profiles payload.
func TestClient_GetRuntimeProfiles_RequestShape(t *testing.T) {
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"workspace_id":"ws-1",
"runtime_profiles":[{
"id":"prof-1",
"workspace_id":"ws-1",
"display_name":"Company Codex",
"protocol_family":"codex",
"command_name":"company-codex",
"description":null,
"fixed_args":["--foo"],
"visibility":"workspace",
"created_by":null,
"enabled":true,
"created_at":"2026-01-01T00:00:00Z",
"updated_at":"2026-01-01T00:00:00Z"
}]
}`))
}))
defer srv.Close()
c := NewClient(srv.URL)
c.SetToken("tok")
resp, err := c.GetRuntimeProfiles(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetRuntimeProfiles: %v", err)
}
if gotMethod != http.MethodGet {
t.Errorf("method = %q, want GET", gotMethod)
}
if gotPath != "/api/daemon/workspaces/ws-1/runtime-profiles" {
t.Errorf("path = %q, want /api/daemon/workspaces/ws-1/runtime-profiles", gotPath)
}
if resp.WorkspaceID != "ws-1" || len(resp.RuntimeProfiles) != 1 {
t.Fatalf("unexpected response: %+v", resp)
}
p := resp.RuntimeProfiles[0]
if p.ID != "prof-1" || p.ProtocolFamily != "codex" || p.CommandName != "company-codex" {
t.Errorf("profile fields wrong: %+v", p)
}
if !p.Enabled {
t.Errorf("profile should be enabled")
}
if len(p.FixedArgs) != 1 || p.FixedArgs[0] != "--foo" {
t.Errorf("fixed_args = %v, want [--foo]", p.FixedArgs)
}
}
// profileRegisterFixture wires a Daemon against a fake server that serves a
// configurable set of runtime profiles and captures the runtimes array sent
// to /api/daemon/register.
type profileRegisterFixture struct {
daemon *Daemon
server *httptest.Server
sentRuntimes []map[string]any
}
func newProfileRegisterFixture(t *testing.T, profiles []RuntimeProfile, profilesStatus int) *profileRegisterFixture {
t.Helper()
fx := &profileRegisterFixture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/daemon/register":
var body struct {
Runtimes []map[string]any `json:"runtimes"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
fx.sentRuntimes = body.Runtimes
// Echo back a Runtime row per requested runtime, threading
// profile_id so the caller can populate runtimeIndex from it.
var resp RegisterResponse
for i, rt := range body.Runtimes {
id := "rt-" + strconv.Itoa(i)
profileID, _ := rt["profile_id"].(string)
typ, _ := rt["type"].(string)
resp.Runtimes = append(resp.Runtimes, Runtime{
ID: id,
Name: "n",
Provider: typ,
Status: "online",
ProfileID: profileID,
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
case len(r.URL.Path) > len("/runtime-profiles") && strings.HasSuffix(r.URL.Path, "/runtime-profiles"):
if profilesStatus != 0 && profilesStatus != http.StatusOK {
w.WriteHeader(profilesStatus)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(RuntimeProfilesResponse{
WorkspaceID: "ws-1",
RuntimeProfiles: profiles,
})
default:
w.WriteHeader(http.StatusOK)
}
}))
t.Cleanup(srv.Close)
d := freshDaemon(srv.URL)
d.profileCommandPaths = make(map[string]string)
fx.daemon = d
fx.server = srv
return fx
}
// TestRegisterRuntimes_AppendsProfileRuntime verifies that a custom profile
// whose command resolves on PATH is appended as a runtime entry carrying
// profile_id, and that its resolved command path is recorded for runTask.
// Uses a custom-only host (no built-in agents) to also prove that path still
// registers.
func TestRegisterRuntimes_AppendsProfileRuntime(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
profiles := []RuntimeProfile{{
ID: "prof-1",
WorkspaceID: "ws-1",
DisplayName: "Company Codex",
ProtocolFamily: "codex",
CommandName: "company-codex",
Visibility: "workspace",
Enabled: true,
}}
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
d := fx.daemon
// Custom-only host: no built-in agents configured.
d.cfg.Agents = map[string]AgentEntry{}
resp, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("registerRuntimesForWorkspace: %v", err)
}
// The register request must carry exactly one runtime: the profile.
if len(fx.sentRuntimes) != 1 {
t.Fatalf("sent runtimes = %d, want 1: %+v", len(fx.sentRuntimes), fx.sentRuntimes)
}
sent := fx.sentRuntimes[0]
if sent["type"] != "codex" {
t.Errorf("sent type = %v, want codex", sent["type"])
}
if sent["profile_id"] != "prof-1" {
t.Errorf("sent profile_id = %v, want prof-1", sent["profile_id"])
}
if sent["status"] != "online" {
t.Errorf("sent status = %v, want online", sent["status"])
}
// The resolved command path must be recorded keyed by profile_id.
if got := d.profileCommandPaths["prof-1"]; got != "/opt/bin/company-codex" {
t.Errorf("profileCommandPaths[prof-1] = %q, want /opt/bin/company-codex", got)
}
// The response runtime carries the profile_id back.
if len(resp.Runtimes) != 1 || resp.Runtimes[0].ProfileID != "prof-1" {
t.Fatalf("response runtimes wrong: %+v", resp.Runtimes)
}
}
// TestRegisterRuntimes_SkipsProfileNotOnPath verifies a profile whose command
// is missing on this host is skipped, and that a host with no built-in agents
// and no resolvable profiles fails registration (len==0 guard preserved).
func TestRegisterRuntimes_SkipsProfileNotOnPath(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
stubLookPath(t, map[string]string{}) // nothing resolves
profiles := []RuntimeProfile{{
ID: "prof-1",
WorkspaceID: "ws-1",
DisplayName: "Company Codex",
ProtocolFamily: "codex",
CommandName: "company-codex",
Enabled: true,
}}
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
d := fx.daemon
d.cfg.Agents = map[string]AgentEntry{}
_, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
if err == nil {
t.Fatalf("expected error when no runtimes resolve, got nil")
}
if _, ok := d.profileCommandPaths["prof-1"]; ok {
t.Errorf("profileCommandPaths should not record an unresolved profile")
}
}
// TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort verifies a 404 from the
// profiles endpoint does not fail registration when a built-in agent exists.
func TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
stubLookPath(t, map[string]string{})
fx := newProfileRegisterFixture(t, nil, http.StatusNotFound)
d := fx.daemon
// Built-in agent present so registration has something to register.
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
resp, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("registration should succeed despite profiles 404: %v", err)
}
if len(fx.sentRuntimes) != 1 || fx.sentRuntimes[0]["type"] != "claude" {
t.Fatalf("expected only the built-in claude runtime, got %+v", fx.sentRuntimes)
}
if len(resp.Runtimes) != 1 {
t.Fatalf("response runtimes = %d, want 1", len(resp.Runtimes))
}
}
// TestRegisterRuntimes_PrefersCommandPathOverride verifies that a per-machine
// command path override (MUL-3284) is used in preference to the PATH lookup:
// the resolved/recorded path is the override, even when lookPath would resolve
// command_name to a different binary.
func TestRegisterRuntimes_PrefersCommandPathOverride(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
// PATH would resolve to a *different* binary; the override must win.
stubLookPath(t, map[string]string{"company-codex": "/usr/bin/company-codex"})
stubProfilePathExecutable(t, map[string]bool{"/opt/custom/company-codex": true})
profiles := []RuntimeProfile{{
ID: "prof-1",
WorkspaceID: "ws-1",
DisplayName: "Company Codex",
ProtocolFamily: "codex",
CommandName: "company-codex",
Enabled: true,
}}
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
d := fx.daemon
d.cfg.Agents = map[string]AgentEntry{}
d.cfg.ProfileCommandOverrides = map[string]string{"prof-1": "/opt/custom/company-codex"}
if _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil {
t.Fatalf("registerRuntimesForWorkspace: %v", err)
}
if got := d.profileCommandPaths["prof-1"]; got != "/opt/custom/company-codex" {
t.Errorf("profileCommandPaths[prof-1] = %q, want the override /opt/custom/company-codex", got)
}
if len(fx.sentRuntimes) != 1 || fx.sentRuntimes[0]["profile_id"] != "prof-1" {
t.Fatalf("expected the profile runtime to register, got %+v", fx.sentRuntimes)
}
}
// TestRegisterRuntimes_OverrideNotExecutableFallsBackToPath verifies that an
// override pointing at a non-executable / missing path is ignored and the
// daemon falls back to resolving command_name on PATH.
func TestRegisterRuntimes_OverrideNotExecutableFallsBackToPath(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
stubLookPath(t, map[string]string{"company-codex": "/usr/bin/company-codex"})
// Override path reports NOT executable -> must fall back to PATH.
stubProfilePathExecutable(t, map[string]bool{})
profiles := []RuntimeProfile{{
ID: "prof-1",
WorkspaceID: "ws-1",
DisplayName: "Company Codex",
ProtocolFamily: "codex",
CommandName: "company-codex",
Enabled: true,
}}
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
d := fx.daemon
d.cfg.Agents = map[string]AgentEntry{}
d.cfg.ProfileCommandOverrides = map[string]string{"prof-1": "/opt/stale/company-codex"}
if _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil {
t.Fatalf("registerRuntimesForWorkspace: %v", err)
}
if got := d.profileCommandPaths["prof-1"]; got != "/usr/bin/company-codex" {
t.Errorf("profileCommandPaths[prof-1] = %q, want the PATH fallback /usr/bin/company-codex", got)
}
}
// stubProfilePathExecutable swaps the package-level profilePathExecutable
// indirection so override-preference tests can decide which paths are
// "executable" without staging real files. An absent path reports false.
func stubProfilePathExecutable(t *testing.T, executable map[string]bool) {
t.Helper()
orig := profilePathExecutable
profilePathExecutable = func(path string) bool { return executable[path] }
t.Cleanup(func() { profilePathExecutable = orig })
}
// bookkeeping that runTask relies on to override the launch path.
func TestCustomCommandPathForRuntime(t *testing.T) {
d := freshDaemon("")
d.profileCommandPaths = map[string]string{"prof-1": "/opt/bin/company-codex"}
// rt-custom is a custom-profile runtime; rt-builtin is a normal one.
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "codex", ProfileID: "prof-1"}
d.runtimeIndex["rt-builtin"] = Runtime{ID: "rt-builtin", Provider: "claude"}
if path, ok := d.customCommandPathForRuntime("rt-custom"); !ok || path != "/opt/bin/company-codex" {
t.Errorf("custom runtime: got (%q, %v), want (/opt/bin/company-codex, true)", path, ok)
}
if path, ok := d.customCommandPathForRuntime("rt-builtin"); ok || path != "" {
t.Errorf("built-in runtime: got (%q, %v), want (\"\", false)", path, ok)
}
if path, ok := d.customCommandPathForRuntime("rt-unknown"); ok || path != "" {
t.Errorf("unknown runtime: got (%q, %v), want (\"\", false)", path, ok)
}
// A custom runtime whose profile path was never resolved on this host
// (profile_id not in profileCommandPaths) must report not-custom so
// runTask falls back to its normal provider lookup rather than launching
// an empty path.
d.runtimeIndex["rt-unresolved"] = Runtime{ID: "rt-unresolved", Provider: "codex", ProfileID: "prof-missing"}
if path, ok := d.customCommandPathForRuntime("rt-unresolved"); ok || path != "" {
t.Errorf("unresolved profile: got (%q, %v), want (\"\", false)", path, ok)
}
}

View File

@@ -14,6 +14,12 @@ type Runtime struct {
Name string `json:"name"`
Provider string `json:"provider"`
Status string `json:"status"`
// ProfileID is non-empty when this runtime was registered from a
// workspace custom runtime profile (MUL-3284). It links the runtime row
// back to the profile so the daemon can resolve the profile's
// command_name to the executable to launch. Built-in (provider-detected)
// runtimes leave this empty.
ProfileID string `json:"profile_id,omitempty"`
}
// RepoData holds repository information from the workspace.

View File

@@ -181,6 +181,11 @@ type DaemonRegisterRequest struct {
Type string `json:"type"`
Version string `json:"version"` // agent CLI version (claude/codex)
Status string `json:"status"`
// ProfileID, when non-empty, marks this as an instance of a custom
// runtime_profile (MUL-3284). Empty = built-in runtime (legacy path).
// Type carries the protocol family for both built-in and custom rows
// so task routing (agent.New) is unchanged.
ProfileID string `json:"profile_id"`
} `json:"runtimes"`
}
@@ -333,51 +338,125 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
"launched_by": req.LaunchedBy,
})
row, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{
WorkspaceID: wsUUID,
DaemonID: strToText(req.DaemonID),
Name: name,
RuntimeMode: "local",
Provider: provider,
Status: status,
DeviceInfo: deviceInfo,
Metadata: metadata,
OwnerID: ownerID,
})
if err != nil {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeFailed(
uuidToString(ownerID),
req.WorkspaceID,
req.DaemonID,
provider,
"registration_failed",
"db_error",
true,
))
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
return
}
var registered db.AgentRuntime
var inserted bool
isCustom := strings.TrimSpace(runtime.ProfileID) != ""
registered := db.AgentRuntime{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
DaemonID: row.DaemonID,
Name: row.Name,
RuntimeMode: row.RuntimeMode,
Provider: row.Provider,
Status: row.Status,
DeviceInfo: row.DeviceInfo,
Metadata: row.Metadata,
LastSeenAt: row.LastSeenAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
OwnerID: row.OwnerID,
LegacyDaemonID: row.LegacyDaemonID,
if isCustom {
profileUUID, pok := parseUUIDOrBadRequest(w, strings.TrimSpace(runtime.ProfileID), "profile_id")
if !pok {
return
}
// The profile must exist in this workspace and be enabled. Trust
// the profile's stored protocol_family over the daemon-sent type so
// the provider used for task routing cannot drift from the profile.
profile, perr := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
ID: profileUUID,
WorkspaceID: wsUUID,
})
if perr != nil {
writeError(w, http.StatusBadRequest, "unknown runtime profile: "+runtime.ProfileID)
return
}
if !profile.Enabled {
writeError(w, http.StatusConflict, "runtime profile is disabled: "+runtime.ProfileID)
return
}
provider = profile.ProtocolFamily
prow, err := h.Queries.UpsertAgentRuntimeWithProfile(r.Context(), db.UpsertAgentRuntimeWithProfileParams{
WorkspaceID: wsUUID,
DaemonID: strToText(req.DaemonID),
Name: name,
RuntimeMode: "local",
Provider: provider,
Status: status,
DeviceInfo: deviceInfo,
Metadata: metadata,
OwnerID: ownerID,
ProfileID: profileUUID,
})
if err != nil {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeFailed(
uuidToString(ownerID),
req.WorkspaceID,
req.DaemonID,
provider,
"registration_failed",
"db_error",
true,
))
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
return
}
inserted = prow.Inserted
registered = db.AgentRuntime{
ID: prow.ID,
WorkspaceID: prow.WorkspaceID,
DaemonID: prow.DaemonID,
Name: prow.Name,
RuntimeMode: prow.RuntimeMode,
Provider: prow.Provider,
Status: prow.Status,
DeviceInfo: prow.DeviceInfo,
Metadata: prow.Metadata,
LastSeenAt: prow.LastSeenAt,
CreatedAt: prow.CreatedAt,
UpdatedAt: prow.UpdatedAt,
OwnerID: prow.OwnerID,
LegacyDaemonID: prow.LegacyDaemonID,
Visibility: prow.Visibility,
ProfileID: prow.ProfileID,
}
} else {
row, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{
WorkspaceID: wsUUID,
DaemonID: strToText(req.DaemonID),
Name: name,
RuntimeMode: "local",
Provider: provider,
Status: status,
DeviceInfo: deviceInfo,
Metadata: metadata,
OwnerID: ownerID,
})
if err != nil {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeFailed(
uuidToString(ownerID),
req.WorkspaceID,
req.DaemonID,
provider,
"registration_failed",
"db_error",
true,
))
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
return
}
inserted = row.Inserted
registered = db.AgentRuntime{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
DaemonID: row.DaemonID,
Name: row.Name,
RuntimeMode: row.RuntimeMode,
Provider: row.Provider,
Status: row.Status,
DeviceInfo: row.DeviceInfo,
Metadata: row.Metadata,
LastSeenAt: row.LastSeenAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
OwnerID: row.OwnerID,
LegacyDaemonID: row.LegacyDaemonID,
Visibility: row.Visibility,
ProfileID: row.ProfileID,
}
}
// Inserted is false for normal daemon reconnects/upserts, so
// runtime_ready is a first-ready-per-runtime-row signal.
if row.Inserted {
if inserted {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.RuntimeRegistered(
uuidToString(ownerID),
req.WorkspaceID,
@@ -404,7 +483,15 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
// (e.g. "host.local", "host", "host-staging"); for each match we
// reassign agents + tasks onto the new UUID-keyed row, then delete
// the stale row so there's only ever one runtime per machine.
h.mergeLegacyRuntimes(r, registered, provider, req.LegacyDaemonIDs)
//
// Only built-in runtimes participate: legacy rows predate custom
// profiles, so a profile-keyed instance never has a hostname-derived
// ancestor to merge, and mergeLegacyRuntimes scopes by provider alone
// (no profile_id), which could otherwise fold a built-in row into a
// custom one of the same provider.
if !isCustom {
h.mergeLegacyRuntimes(r, registered, provider, req.LegacyDaemonIDs)
}
resp = append(resp, runtimeToResponse(registered))
}

View File

@@ -33,6 +33,9 @@ type AgentRuntimeResponse struct {
// can bind agents) or "public" (any workspace member can). See migration
// 083 and canUseRuntimeForAgent.
Visibility string `json:"visibility"`
// ProfileID is set when this runtime is an instance of a custom
// runtime_profile (MUL-3284); null for built-in runtimes.
ProfileID *string `json:"profile_id"`
LastSeenAt *string `json:"last_seen_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -60,6 +63,7 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
Metadata: metadata,
OwnerID: uuidToPtr(rt.OwnerID),
Visibility: rt.Visibility,
ProfileID: uuidToPtr(rt.ProfileID),
LastSeenAt: timestampToPtr(rt.LastSeenAt),
CreatedAt: timestampToString(rt.CreatedAt),
UpdatedAt: timestampToString(rt.UpdatedAt),

View File

@@ -0,0 +1,477 @@
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/pkg/agent"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ---------------------------------------------------------------------------
// Custom Runtime Profiles (MUL-3284)
//
// A runtime_profile is a workspace-level, team-shared definition of a custom
// runtime — e.g. an in-house Codex wrapper. Daemons pull the enabled profiles
// for their workspace, resolve command_name on PATH, and register an
// agent_runtime instance carrying the profile_id. The profile only changes how
// a runtime is launched/displayed; the underlying protocol_family must be a
// backend Multica officially supports (validated against agent.SupportedTypes).
//
// Iron rule: a profile carries NO generic per-agent args. Per-agent launch args
// stay on agent.custom_args. The only args field is fixed_args — args every
// agent on this runtime must inherit to enter a compatible mode.
// ---------------------------------------------------------------------------
type RuntimeProfileResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
DisplayName string `json:"display_name"`
ProtocolFamily string `json:"protocol_family"`
CommandName string `json:"command_name"`
Description *string `json:"description"`
FixedArgs []string `json:"fixed_args"`
Visibility string `json:"visibility"`
CreatedBy *string `json:"created_by"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func runtimeProfileToResponse(p db.RuntimeProfile) RuntimeProfileResponse {
args := []string{}
if len(p.FixedArgs) > 0 {
_ = json.Unmarshal(p.FixedArgs, &args)
if args == nil {
args = []string{}
}
}
return RuntimeProfileResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
DisplayName: p.DisplayName,
ProtocolFamily: p.ProtocolFamily,
CommandName: p.CommandName,
Description: textToPtr(p.Description),
FixedArgs: args,
Visibility: p.Visibility,
CreatedBy: uuidToPtr(p.CreatedBy),
Enabled: p.Enabled,
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
}
// NOTE: runtime_profile.visibility is intentionally NOT user-settable in v1.
// The column exists and the API still returns it, but creation always forces
// 'workspace': the daemon-pull, DaemonRegister and ListRuntimeProfiles read
// paths do not yet enforce 'private', so accepting 'private' from a client
// would silently leak a "private" profile's name/command to other members and
// let other machines' daemons register it (lateral data leak). Re-expose a
// visibility control only once those read paths enforce creator visibility.
// Follow-up: MUL-3308.
const runtimeProfileDefaultVisibility = "workspace"
// marshalFixedArgs validates and JSON-encodes the fixed_args list. Each entry
// must be a non-empty string; the column defaults to an empty array.
func marshalFixedArgs(args []string) ([]byte, error) {
if len(args) == 0 {
return []byte("[]"), nil
}
clean := make([]string, 0, len(args))
for _, a := range args {
// fixed_args are launch flags inherited by every agent on the runtime;
// blank entries are always a client mistake.
if strings.TrimSpace(a) == "" {
return nil, errors.New("fixed_args entries must be non-empty")
}
clean = append(clean, a)
}
return json.Marshal(clean)
}
type createRuntimeProfileRequest struct {
DisplayName string `json:"display_name"`
ProtocolFamily string `json:"protocol_family"`
CommandName string `json:"command_name"`
Description *string `json:"description"`
FixedArgs []string `json:"fixed_args"`
Enabled *bool `json:"enabled"`
}
// CreateRuntimeProfile creates a workspace runtime profile. Admin-gated by the
// router. protocol_family is validated against the agent backend whitelist.
func (h *Handler) CreateRuntimeProfile(w http.ResponseWriter, r *http.Request) {
wsID := strings.TrimSpace(chi.URLParam(r, "id"))
member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id")
if !ok {
return
}
var req createRuntimeProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.DisplayName = strings.TrimSpace(req.DisplayName)
req.ProtocolFamily = strings.TrimSpace(req.ProtocolFamily)
req.CommandName = strings.TrimSpace(req.CommandName)
if req.DisplayName == "" {
writeError(w, http.StatusBadRequest, "display_name is required")
return
}
if !agent.IsSupportedType(req.ProtocolFamily) {
writeError(w, http.StatusBadRequest, "unsupported protocol_family: must be one of "+strings.Join(agent.SupportedTypes, ", "))
return
}
if req.CommandName == "" {
writeError(w, http.StatusBadRequest, "command_name is required")
return
}
fixedArgs, err := marshalFixedArgs(req.FixedArgs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
profile, err := h.Queries.CreateRuntimeProfile(r.Context(), db.CreateRuntimeProfileParams{
WorkspaceID: wsUUID,
DisplayName: req.DisplayName,
ProtocolFamily: req.ProtocolFamily,
CommandName: req.CommandName,
Description: ptrToText(req.Description),
FixedArgs: fixedArgs,
Visibility: runtimeProfileDefaultVisibility,
CreatedBy: member.UserID,
Enabled: enabled,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a runtime profile with this display_name already exists")
return
}
slog.Error("CreateRuntimeProfile failed", "error", err, "workspace_id", wsID)
writeError(w, http.StatusInternalServerError, "failed to create runtime profile")
return
}
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"runtime_profile_id": uuidToString(profile.ID),
})
writeJSON(w, http.StatusCreated, runtimeProfileToResponse(profile))
}
// ListRuntimeProfiles returns every runtime profile in the workspace.
// Member-gated by the router.
func (h *Handler) ListRuntimeProfiles(w http.ResponseWriter, r *http.Request) {
wsID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found"); !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id")
if !ok {
return
}
profiles, err := h.Queries.ListRuntimeProfiles(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list runtime profiles")
return
}
resp := make([]RuntimeProfileResponse, len(profiles))
for i, p := range profiles {
resp[i] = runtimeProfileToResponse(p)
}
writeJSON(w, http.StatusOK, map[string]any{"runtime_profiles": resp})
}
// GetRuntimeProfile returns one runtime profile. Member-gated by the router.
func (h *Handler) GetRuntimeProfile(w http.ResponseWriter, r *http.Request) {
wsID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found"); !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id")
if !ok {
return
}
profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id")
if !ok {
return
}
profile, err := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
ID: profileUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "runtime profile not found")
return
}
writeJSON(w, http.StatusOK, runtimeProfileToResponse(profile))
}
type updateRuntimeProfileRequest struct {
DisplayName *string `json:"display_name"`
CommandName *string `json:"command_name"`
Description *string `json:"description"`
FixedArgs *[]string `json:"fixed_args"`
Enabled *bool `json:"enabled"`
}
// UpdateRuntimeProfile applies a partial update. protocol_family is immutable
// (changing it would silently repoint bound agents onto a different backend).
// Admin-gated by the router.
func (h *Handler) UpdateRuntimeProfile(w http.ResponseWriter, r *http.Request) {
wsID := strings.TrimSpace(chi.URLParam(r, "id"))
member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id")
if !ok {
return
}
profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id")
if !ok {
return
}
var req updateRuntimeProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateRuntimeProfileParams{ID: profileUUID, WorkspaceID: wsUUID}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if name == "" {
writeError(w, http.StatusBadRequest, "display_name cannot be empty")
return
}
params.DisplayName = strToText(name)
}
if req.CommandName != nil {
cmd := strings.TrimSpace(*req.CommandName)
if cmd == "" {
writeError(w, http.StatusBadRequest, "command_name cannot be empty")
return
}
params.CommandName = strToText(cmd)
}
if req.Description != nil {
params.Description = ptrToText(req.Description)
}
if req.FixedArgs != nil {
fixedArgs, err := marshalFixedArgs(*req.FixedArgs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
params.FixedArgs = fixedArgs
}
if req.Enabled != nil {
params.Enabled = pgtype.Bool{Bool: *req.Enabled, Valid: true}
}
profile, err := h.Queries.UpdateRuntimeProfile(r.Context(), params)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "runtime profile not found")
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a runtime profile with this display_name already exists")
return
}
slog.Error("UpdateRuntimeProfile failed", "error", err, "profile_id", uuidToString(profileUUID))
writeError(w, http.StatusInternalServerError, "failed to update runtime profile")
return
}
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"runtime_profile_id": uuidToString(profile.ID),
})
writeJSON(w, http.StatusOK, runtimeProfileToResponse(profile))
}
// DeleteRuntimeProfile removes a profile and, in the same transaction, the
// agent_runtime instance rows registered against it. Migration 120 dropped the
// DB ON DELETE CASCADE, so this app-layer cleanup is what prevents orphaned
// runtime rows. Refuses (409) while active agents are still bound to the
// profile's runtimes. Admin-gated by the router.
func (h *Handler) DeleteRuntimeProfile(w http.ResponseWriter, r *http.Request) {
wsID := strings.TrimSpace(chi.URLParam(r, "id"))
member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id")
if !ok {
return
}
profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id")
if !ok {
return
}
// Confirm the profile exists in this workspace before mutating anything.
if _, err := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
ID: profileUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "runtime profile not found")
return
}
// Enumerate the runtime instance rows registered against this profile.
// The profile-delete cascade must run the SAME teardown the runtime-delete
// path uses for each one: agent.runtime_id is ON DELETE RESTRICT, so an
// archived agent still pointing at one of these rows would turn a bare
// delete into a 500. We refuse active agents (409) and clean archived
// agents / their archived squad+autopilot references before deleting.
runtimeIDs, err := h.Queries.ListAgentRuntimeIDsByProfile(r.Context(), profileUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate profile runtimes")
return
}
// Guard 1: refuse while any active (non-archived) agent is bound to one of
// the profile's runtimes. Keep this a 409 — deleting would orphan live
// agents.
agentCount, err := h.Queries.CountAgentsByProfile(r.Context(), profileUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check profile usage")
return
}
if agentCount > 0 {
writeError(w, http.StatusConflict, "cannot delete runtime profile: active agents are still bound to its runtimes")
return
}
// Guard 2: refuse (before any teardown) if any runtime still has an active
// squad whose leader is already archived on it — same rule the
// runtime-delete path enforces. Checked per runtime up front so we never
// half-tear-down and then 409.
for _, rid := range runtimeIDs {
activeSquadCount, err := h.Queries.CountActiveSquadsWithArchivedLeadersByRuntime(r.Context(), rid)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check runtime squad dependencies")
return
}
if activeSquadCount > 0 {
writeError(w, http.StatusConflict, "cannot delete runtime profile: a runtime has active squads led by archived agents. Archive those squads or assign them a new leader first.")
return
}
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to begin transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
// App-layer cascade, per runtime, mirroring DeleteAgentRuntime: pause
// autopilots pointing at the archived agents, drop archived squads led by
// them, then hard-delete the archived agents so the RESTRICT FK on
// agent.runtime_id no longer blocks removing the runtime row.
for _, rid := range runtimeIDs {
archivedAgentIDs, err := qtx.ListArchivedAgentIDsByRuntime(r.Context(), rid)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate archived agents")
return
}
if len(archivedAgentIDs) > 0 {
if err := qtx.PauseAutopilotsByAgentAssignees(r.Context(), archivedAgentIDs); err != nil {
writeError(w, http.StatusInternalServerError, "failed to pause autopilots")
return
}
}
if err := qtx.DeleteSquadsByArchivedAgentsOnRuntime(r.Context(), rid); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up squads referencing archived agents")
return
}
if err := qtx.DeleteArchivedAgentsByRuntime(r.Context(), rid); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up archived agents")
return
}
}
// Now the runtime rows have no agent references; remove them, then the
// profile itself.
if _, err := qtx.DeleteAgentRuntimesByProfile(r.Context(), profileUUID); err != nil {
slog.Error("DeleteAgentRuntimesByProfile failed", "error", err, "profile_id", uuidToString(profileUUID))
writeError(w, http.StatusInternalServerError, "failed to clean up runtime instances")
return
}
if err := qtx.DeleteRuntimeProfile(r.Context(), db.DeleteRuntimeProfileParams{
ID: profileUUID,
WorkspaceID: wsUUID,
}); err != nil {
slog.Error("DeleteRuntimeProfile failed", "error", err, "profile_id", uuidToString(profileUUID))
writeError(w, http.StatusInternalServerError, "failed to delete runtime profile")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit transaction")
return
}
// Tell connected clients to refetch the runtime list (instances vanished).
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"deleted_runtime_profile_id": uuidToString(profileUUID),
})
w.WriteHeader(http.StatusNoContent)
}
// DaemonListRuntimeProfiles serves the enabled runtime profiles for a workspace
// to a daemon. The daemon resolves each profile's command_name on PATH and
// registers an agent_runtime instance per profile it can run. Daemon-token
// gated by the router.
func (h *Handler) DaemonListRuntimeProfiles(w http.ResponseWriter, r *http.Request) {
workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceId"))
if !h.requireDaemonWorkspaceAccess(w, r, workspaceID) {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
profiles, err := h.Queries.ListEnabledRuntimeProfilesForWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list runtime profiles")
return
}
resp := make([]RuntimeProfileResponse, len(profiles))
for i, p := range profiles {
resp[i] = runtimeProfileToResponse(p)
}
writeJSON(w, http.StatusOK, map[string]any{
"workspace_id": workspaceID,
"runtime_profiles": resp,
})
}

View File

@@ -0,0 +1,185 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// insertRuntimeProfileFixture creates a runtime_profile in testWorkspaceID and
// returns its id, registering cleanup.
func insertRuntimeProfileFixture(t *testing.T, ctx context.Context, displayName, protocolFamily, commandName string) string {
t.Helper()
var profileID string
if err := testPool.QueryRow(ctx, `
INSERT INTO runtime_profile (workspace_id, display_name, protocol_family, command_name, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, testWorkspaceID, displayName, protocolFamily, commandName, testUserID).Scan(&profileID); err != nil {
t.Fatalf("insert runtime_profile fixture: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, profileID)
})
return profileID
}
// insertProfileRuntimeFixture creates an agent_runtime instance bound to the
// given profile (so profile_id is set), returning its id.
func insertProfileRuntimeFixture(t *testing.T, ctx context.Context, profileID, name, provider string) string {
t.Helper()
var runtimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, profile_id, last_seen_at
)
VALUES ($1, NULL, $2, 'local', $3, 'online', $4, '{}'::jsonb, $5, $6, now())
RETURNING id
`, testWorkspaceID, name, provider, name+" device", testUserID, profileID).Scan(&runtimeID); err != nil {
t.Fatalf("insert profile runtime fixture: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, runtimeID)
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
})
return runtimeID
}
// TestDeleteRuntimeProfile_ArchivedAgentCascade is the regression guard for the
// FK-RESTRICT 500: a profile whose only remaining agent is ARCHIVED must still
// delete cleanly. agent.runtime_id is ON DELETE RESTRICT, so without the
// per-runtime archived-agent teardown the DELETE on agent_runtime would raise a
// raw FK error and the handler would 500. The cascade must hard-delete the
// archived agent, the runtime row, and the profile.
func TestDeleteRuntimeProfile_ArchivedAgentCascade(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
profileID := insertRuntimeProfileFixture(t, ctx, "Cascade Profile Archived", "codex", "company-codex-arch")
runtimeID := insertProfileRuntimeFixture(t, ctx, profileID, "Cascade Profile Runtime", "codex")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Profile Archived Agent")
// Archive the agent — the active-agent guard passes, but the FK still pins
// the runtime row until the archived cascade clears it.
if _, err := testPool.Exec(ctx, `UPDATE agent SET archived_at = now() WHERE id = $1`, agentID); err != nil {
t.Fatalf("archive agent: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+profileID, nil)
req = withURLParams(req, "id", testWorkspaceID, "profileId", profileID)
testHandler.DeleteRuntimeProfile(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
}
var profileRows, rtRows, agentRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM runtime_profile WHERE id = $1`, profileID).Scan(&profileRows); err != nil {
t.Fatalf("count profile rows: %v", err)
}
if profileRows != 0 {
t.Fatalf("expected profile deleted, found %d", profileRows)
}
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 0 {
t.Fatalf("expected runtime row deleted by cascade, found %d", rtRows)
}
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent WHERE id = $1`, agentID).Scan(&agentRows); err != nil {
t.Fatalf("count agent rows: %v", err)
}
if agentRows != 0 {
t.Fatalf("expected archived agent hard-deleted by cascade, found %d", agentRows)
}
}
// TestDeleteRuntimeProfile_ActiveAgentBlocks confirms the guard still refuses
// (409) while an ACTIVE agent is bound to one of the profile's runtimes, and
// leaves the profile + runtime intact.
func TestDeleteRuntimeProfile_ActiveAgentBlocks(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
profileID := insertRuntimeProfileFixture(t, ctx, "Cascade Profile Active", "codex", "company-codex-active")
runtimeID := insertProfileRuntimeFixture(t, ctx, profileID, "Cascade Profile Active Runtime", "codex")
_ = createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Profile Active Agent")
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+profileID, nil)
req = withURLParams(req, "id", testWorkspaceID, "profileId", profileID)
testHandler.DeleteRuntimeProfile(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var profileRows, rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM runtime_profile WHERE id = $1`, profileID).Scan(&profileRows); err != nil {
t.Fatalf("count profile rows: %v", err)
}
if profileRows != 1 {
t.Fatalf("expected profile to survive 409, found %d", profileRows)
}
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 1 {
t.Fatalf("expected runtime to survive 409, found %d", rtRows)
}
}
// TestCreateRuntimeProfile_ForcesWorkspaceVisibility is the regression guard
// for the visibility leak: visibility=private is not user-settable in v1
// because the read paths don't enforce it. A client that POSTs
// visibility:"private" must get a profile stored as 'workspace' — never
// private — so a "private" profile can't leak to other members or be
// registered by other daemons. Belt-and-suspenders: also assert the row in
// the DB is 'workspace'.
func TestCreateRuntimeProfile_ForcesWorkspaceVisibility(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles", map[string]any{
"display_name": "Visibility Forced Profile",
"protocol_family": "codex",
"command_name": "vis-forced-codex",
"visibility": "private", // must be ignored
})
req = withURLParam(req, "id", testWorkspaceID)
testHandler.CreateRuntimeProfile(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp RuntimeProfileResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, resp.ID)
})
if resp.Visibility != "workspace" {
t.Fatalf("response visibility = %q, want workspace (private must be forced to workspace)", resp.Visibility)
}
var dbVis string
if err := testPool.QueryRow(ctx, `SELECT visibility FROM runtime_profile WHERE id = $1`, resp.ID).Scan(&dbVis); err != nil {
t.Fatalf("read stored visibility: %v", err)
}
if dbVis != "workspace" {
t.Fatalf("stored visibility = %q, want workspace", dbVis)
}
}

View File

@@ -0,0 +1,13 @@
-- Reverse 120_runtime_profile.up.sql. No DB foreign keys were added by the up
-- migration (relationships are enforced in the application layer), so ordering
-- here only needs to drop dependent index/column before the table they live
-- alongside.
DROP INDEX IF EXISTS agent_runtime_workspace_daemon_profile_key;
ALTER TABLE agent_runtime
DROP COLUMN IF EXISTS profile_id;
DROP INDEX IF EXISTS idx_runtime_profile_workspace;
DROP TABLE IF EXISTS runtime_profile;

View File

@@ -0,0 +1,83 @@
-- Custom Runtime, PR1 (schema only). See MUL-3284 / GitHub issue #3667.
--
-- Adds the workspace-level `runtime_profile` table (the shared, team-visible
-- definition of a "custom runtime" — e.g. an in-house Codex wrapper) and gives
-- `agent_runtime` a stable `profile_id` so the same daemon can host multiple
-- runtimes of the same protocol family.
--
-- Referential integrity policy (house rule): this migration does NOT add any
-- new database foreign keys or ON DELETE cascades. `workspace_id`,
-- `created_by` and `agent_runtime.profile_id` are plain UUID columns; the
-- relationships they model are enforced in the application layer, not by the
-- database. In particular, deleting a runtime_profile must clean up its
-- associated agent_runtime instance rows in application code (PR2's profile
-- delete path) — the database will no longer cascade that for us.
--
-- Scope is deliberately additive only:
-- * The legacy `UNIQUE (workspace_id, daemon_id, provider)` constraint on
-- agent_runtime is left INTACT so the existing registration upsert
-- (`ON CONFLICT (workspace_id, daemon_id, provider)` in runtime.sql) keeps
-- resolving its arbiter. Converting that key into a partial index
-- (WHERE profile_id IS NULL) and teaching the upsert to be profile-aware
-- is PR2's registration work, not this migration's.
-- * `profile_id` is NULL for every existing/built-in runtime row, so the new
-- partial unique index does not constrain any current data.
--
-- Iron rule honored here at the schema level: the profile does NOT carry a
-- generic per-agent args field. Per-agent launch args continue to live on
-- `agent.custom_args`. The only args column is `fixed_args` — the fixed
-- arguments that EVERY agent on this runtime must inherit to enter a
-- compatible mode (advanced/optional, defaults to an empty array).
CREATE TABLE runtime_profile (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Owning workspace. Plain UUID; integrity (and cleanup on workspace
-- delete) is enforced in the application layer, not by a DB FK.
workspace_id UUID NOT NULL,
display_name TEXT NOT NULL,
-- protocol_family must stay in lockstep with the agent.New() switch in
-- server/pkg/agent/agent.go. A profile may only be based on a backend
-- Multica already officially supports and tests.
protocol_family TEXT NOT NULL CHECK (protocol_family IN (
'claude',
'codebuddy',
'codex',
'copilot',
'opencode',
'openclaw',
'hermes',
'gemini',
'pi',
'cursor',
'kimi',
'kiro',
'antigravity'
)),
command_name TEXT NOT NULL,
description TEXT,
fixed_args JSONB NOT NULL DEFAULT '[]',
visibility TEXT NOT NULL DEFAULT 'workspace' CHECK (visibility IN ('workspace', 'private')),
-- Creating user. Plain UUID, nullable; no DB FK.
created_by UUID,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, display_name)
);
CREATE INDEX idx_runtime_profile_workspace ON runtime_profile(workspace_id);
-- Stable profile identity on the runtime instance row. NULL = built-in runtime
-- (registered the legacy way); non-NULL = a registered instance of a custom
-- profile. Plain UUID with no DB FK: the link to runtime_profile, and the
-- cleanup of these rows when a profile is deleted, is the application layer's
-- responsibility (PR2).
ALTER TABLE agent_runtime
ADD COLUMN profile_id UUID;
-- Custom-runtime uniqueness: one instance per (workspace, daemon, profile).
-- Partial so it never touches built-in rows (profile_id IS NULL) and never
-- conflicts with the legacy (workspace_id, daemon_id, provider) constraint.
CREATE UNIQUE INDEX agent_runtime_workspace_daemon_profile_key
ON agent_runtime (workspace_id, daemon_id, profile_id)
WHERE profile_id IS NOT NULL;

View File

@@ -0,0 +1,15 @@
-- Reverse 121_agent_runtime_provider_partial_unique.up.sql.
--
-- Restoring the non-partial constraint requires that no two rows share
-- (workspace_id, daemon_id, provider). Custom-runtime rows (profile_id IS NOT
-- NULL) can violate that if a built-in and a custom runtime of the same
-- provider coexist on one daemon, so a clean downgrade assumes such rows have
-- been removed first (PR2's feature being rolled back). The DROP INDEX is
-- unconditional; the ADD CONSTRAINT will fail loudly if duplicates remain,
-- which is the correct, non-silent behavior for a rollback.
DROP INDEX IF EXISTS agent_runtime_workspace_daemon_provider_key;
ALTER TABLE agent_runtime
ADD CONSTRAINT agent_runtime_workspace_id_daemon_id_provider_key
UNIQUE (workspace_id, daemon_id, provider);

View File

@@ -0,0 +1,28 @@
-- Custom Runtime, PR2 (registration extension). See MUL-3284 / GitHub #3667.
--
-- PR1 (migration 120) added agent_runtime.profile_id and a partial unique index
-- for custom-runtime instances, but deliberately left the legacy
-- UNIQUE (workspace_id, daemon_id, provider) constraint intact so the existing
-- registration upsert kept working. That non-partial constraint blocks a
-- built-in runtime (profile_id IS NULL) and a custom runtime of the SAME
-- protocol family (provider) from coexisting on one daemon.
--
-- PR2 makes registration profile-aware, so we now convert that legacy key into
-- a PARTIAL unique index scoped to built-in rows only (profile_id IS NULL).
-- Combined with 120's partial index on (workspace_id, daemon_id, profile_id)
-- WHERE profile_id IS NOT NULL, this lets a single daemon host the built-in
-- codex AND any number of custom codex-based profiles without collision, while
-- still enforcing one built-in runtime per (workspace, daemon, provider).
--
-- The matching upserts now spell out the predicate in their ON CONFLICT
-- arbiter (see pkg/db/queries/runtime.sql): built-in registration targets
-- (workspace_id, daemon_id, provider) WHERE profile_id IS NULL; custom
-- registration targets (workspace_id, daemon_id, profile_id) WHERE
-- profile_id IS NOT NULL.
ALTER TABLE agent_runtime
DROP CONSTRAINT agent_runtime_workspace_id_daemon_id_provider_key;
CREATE UNIQUE INDEX agent_runtime_workspace_daemon_provider_key
ON agent_runtime (workspace_id, daemon_id, provider)
WHERE profile_id IS NULL;

View File

@@ -134,6 +134,39 @@ type Config struct {
// New creates a Backend for the given agent type.
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro", "antigravity".
//
// SupportedTypes is the canonical whitelist of agent types New can construct.
// It MUST stay in lockstep with the switch in New below and the
// runtime_profile.protocol_family CHECK constraint (migration 120): a custom
// runtime profile may only be based on a backend Multica officially supports.
var SupportedTypes = []string{
"claude",
"codebuddy",
"codex",
"copilot",
"opencode",
"openclaw",
"hermes",
"gemini",
"pi",
"cursor",
"kimi",
"kiro",
"antigravity",
}
// IsSupportedType reports whether agentType is in the SupportedTypes whitelist.
// Used to validate a custom runtime profile's protocol_family before it is
// persisted or registered.
func IsSupportedType(agentType string) bool {
for _, t := range SupportedTypes {
if t == agentType {
return true
}
}
return false
}
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()

View File

@@ -0,0 +1,52 @@
package agent
import (
"log/slog"
"testing"
)
// TestSupportedTypesLockstepWithNew guards the iron-rule whitelist: every type
// in SupportedTypes must be constructable by New, and New must reject anything
// not in SupportedTypes. This is the single source of truth the custom runtime
// profile protocol_family validation (handler) and the runtime_profile
// protocol_family CHECK (migration 120) are aligned to. If a backend is added
// to New, it must be added here too — and to the migration CHECK.
func TestSupportedTypesLockstepWithNew(t *testing.T) {
cfg := Config{Logger: slog.Default()}
for _, typ := range SupportedTypes {
if !IsSupportedType(typ) {
t.Errorf("IsSupportedType(%q) = false, but it is in SupportedTypes", typ)
}
if _, err := New(typ, cfg); err != nil {
t.Errorf("New(%q) returned error for a SupportedTypes entry: %v", typ, err)
}
}
// A type outside the whitelist must be rejected by both.
const bogus = "definitely-not-a-real-backend"
if IsSupportedType(bogus) {
t.Errorf("IsSupportedType(%q) = true, want false", bogus)
}
if _, err := New(bogus, cfg); err == nil {
t.Errorf("New(%q) succeeded, want error for an unsupported type", bogus)
}
}
// TestSupportedTypesMatchesMigrationWhitelist pins the exact set so a drift
// from the runtime_profile.protocol_family CHECK in migration 120 fails loudly.
func TestSupportedTypesMatchesMigrationWhitelist(t *testing.T) {
want := map[string]bool{
"claude": true, "codebuddy": true, "codex": true, "copilot": true,
"opencode": true, "openclaw": true, "hermes": true, "gemini": true,
"pi": true, "cursor": true, "kimi": true, "kiro": true, "antigravity": true,
}
if len(SupportedTypes) != len(want) {
t.Fatalf("SupportedTypes has %d entries, migration whitelist has %d; keep them in lockstep", len(SupportedTypes), len(want))
}
for _, typ := range SupportedTypes {
if !want[typ] {
t.Errorf("SupportedTypes contains %q which is not in the migration 120 protocol_family CHECK", typ)
}
}
}

View File

@@ -62,6 +62,7 @@ type AgentRuntime struct {
OwnerID pgtype.UUID `json:"owner_id"`
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
Visibility string `json:"visibility"`
ProfileID pgtype.UUID `json:"profile_id"`
}
type AgentSkill struct {
@@ -550,6 +551,21 @@ type ProjectResource struct {
CreatedBy pgtype.UUID `json:"created_by"`
}
type RuntimeProfile struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
DisplayName string `json:"display_name"`
ProtocolFamily string `json:"protocol_family"`
CommandName string `json:"command_name"`
Description pgtype.Text `json:"description"`
FixedArgs []byte `json:"fixed_args"`
Visibility string `json:"visibility"`
CreatedBy pgtype.UUID `json:"created_by"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Skill struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View File

@@ -249,7 +249,7 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context) ([]AgentTaskQ
}
const findLegacyRuntimesByDaemonID = `-- name: FindLegacyRuntimesByDaemonID :many
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE workspace_id = $1
AND provider = $2
AND LOWER(daemon_id) = LOWER($3)
@@ -301,6 +301,7 @@ func (q *Queries) FindLegacyRuntimesByDaemonID(ctx context.Context, arg FindLega
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
); err != nil {
return nil, err
}
@@ -360,7 +361,7 @@ func (q *Queries) ForceOfflineRuntimesByIDs(ctx context.Context, runtimeIds []pg
}
const getAgentRuntime = `-- name: GetAgentRuntime :one
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE id = $1
`
@@ -383,12 +384,13 @@ func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRun
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
)
return i, err
}
const getAgentRuntimeForWorkspace = `-- name: GetAgentRuntimeForWorkspace :one
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE id = $1 AND workspace_id = $2
`
@@ -416,12 +418,13 @@ func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentR
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
)
return i, err
}
const listAgentRuntimes = `-- name: ListAgentRuntimes :many
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE workspace_id = $1
ORDER BY created_at ASC
`
@@ -451,6 +454,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
); err != nil {
return nil, err
}
@@ -463,7 +467,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID
}
const listAgentRuntimesByOwner = `-- name: ListAgentRuntimesByOwner :many
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE workspace_id = $1 AND owner_id = $2
ORDER BY created_at ASC
`
@@ -498,6 +502,7 @@ func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRun
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
); err != nil {
return nil, err
}
@@ -537,7 +542,7 @@ func (q *Queries) ListArchivedAgentIDsByRuntime(ctx context.Context, runtimeID p
}
const lockAgentRuntime = `-- name: LockAgentRuntime :one
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility FROM agent_runtime
SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id FROM agent_runtime
WHERE id = $1
FOR UPDATE
`
@@ -575,6 +580,7 @@ func (q *Queries) LockAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRu
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
)
return i, err
}
@@ -583,7 +589,7 @@ const markAgentRuntimeOnline = `-- name: MarkAgentRuntimeOnline :one
UPDATE agent_runtime
SET status = 'online', last_seen_at = now(), updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id
`
// Used on the offline→online transition (and on first heartbeat after
@@ -608,6 +614,7 @@ func (q *Queries) MarkAgentRuntimeOnline(ctx context.Context, id pgtype.UUID) (A
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
)
return i, err
}
@@ -860,7 +867,7 @@ const updateAgentRuntimeVisibility = `-- name: UpdateAgentRuntimeVisibility :one
UPDATE agent_runtime
SET visibility = $1, updated_at = now()
WHERE id = $2
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id
`
type UpdateAgentRuntimeVisibilityParams struct {
@@ -891,6 +898,7 @@ func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAg
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
)
return i, err
}
@@ -908,7 +916,7 @@ INSERT INTO agent_runtime (
owner_id,
last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (workspace_id, daemon_id, provider)
ON CONFLICT (workspace_id, daemon_id, provider) WHERE profile_id IS NULL
DO UPDATE SET
name = EXCLUDED.name,
runtime_mode = EXCLUDED.runtime_mode,
@@ -918,7 +926,7 @@ DO UPDATE SET
owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id),
last_seen_at = now(),
updated_at = now()
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, (xmax = 0) AS inserted
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, (xmax = 0) AS inserted
`
type UpsertAgentRuntimeParams struct {
@@ -949,12 +957,17 @@ type UpsertAgentRuntimeRow struct {
OwnerID pgtype.UUID `json:"owner_id"`
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
Visibility string `json:"visibility"`
ProfileID pgtype.UUID `json:"profile_id"`
Inserted bool `json:"inserted"`
}
// (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.
// 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).
func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntimeParams) (UpsertAgentRuntimeRow, error) {
row := q.db.QueryRow(ctx, upsertAgentRuntime,
arg.WorkspaceID,
@@ -984,6 +997,111 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
&i.Inserted,
)
return i, err
}
const upsertAgentRuntimeWithProfile = `-- name: UpsertAgentRuntimeWithProfile :one
INSERT INTO agent_runtime (
workspace_id,
daemon_id,
name,
runtime_mode,
provider,
status,
device_info,
metadata,
owner_id,
profile_id,
last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 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()
RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id, legacy_daemon_id, visibility, profile_id, (xmax = 0) AS inserted
`
type UpsertAgentRuntimeWithProfileParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID pgtype.Text `json:"daemon_id"`
Name string `json:"name"`
RuntimeMode string `json:"runtime_mode"`
Provider string `json:"provider"`
Status string `json:"status"`
DeviceInfo string `json:"device_info"`
Metadata []byte `json:"metadata"`
OwnerID pgtype.UUID `json:"owner_id"`
ProfileID pgtype.UUID `json:"profile_id"`
}
type UpsertAgentRuntimeWithProfileRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID pgtype.Text `json:"daemon_id"`
Name string `json:"name"`
RuntimeMode string `json:"runtime_mode"`
Provider string `json:"provider"`
Status string `json:"status"`
DeviceInfo string `json:"device_info"`
Metadata []byte `json:"metadata"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
OwnerID pgtype.UUID `json:"owner_id"`
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
Visibility string `json:"visibility"`
ProfileID pgtype.UUID `json:"profile_id"`
Inserted bool `json:"inserted"`
}
// 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 stays the protocol family so
// task routing (agent.New(provider)) is unchanged; profile_id is the stable
// identity. (xmax = 0) AS inserted mirrors UpsertAgentRuntime.
func (q *Queries) UpsertAgentRuntimeWithProfile(ctx context.Context, arg UpsertAgentRuntimeWithProfileParams) (UpsertAgentRuntimeWithProfileRow, error) {
row := q.db.QueryRow(ctx, upsertAgentRuntimeWithProfile,
arg.WorkspaceID,
arg.DaemonID,
arg.Name,
arg.RuntimeMode,
arg.Provider,
arg.Status,
arg.DeviceInfo,
arg.Metadata,
arg.OwnerID,
arg.ProfileID,
)
var i UpsertAgentRuntimeWithProfileRow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.DaemonID,
&i.Name,
&i.RuntimeMode,
&i.Provider,
&i.Status,
&i.DeviceInfo,
&i.Metadata,
&i.LastSeenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.LegacyDaemonID,
&i.Visibility,
&i.ProfileID,
&i.Inserted,
)
return i, err

View File

@@ -0,0 +1,370 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: runtime_profile.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAgentsByProfile = `-- name: CountAgentsByProfile :one
SELECT count(*) FROM agent a
JOIN agent_runtime ar ON ar.id = a.runtime_id
WHERE ar.profile_id = $1 AND a.archived_at IS NULL
`
// Counts 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.
func (q *Queries) CountAgentsByProfile(ctx context.Context, profileID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countAgentsByProfile, profileID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRuntimeProfile = `-- name: CreateRuntimeProfile :one
INSERT INTO runtime_profile (
workspace_id,
display_name,
protocol_family,
command_name,
description,
fixed_args,
visibility,
created_by,
enabled
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at
`
type CreateRuntimeProfileParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
DisplayName string `json:"display_name"`
ProtocolFamily string `json:"protocol_family"`
CommandName string `json:"command_name"`
Description pgtype.Text `json:"description"`
FixedArgs []byte `json:"fixed_args"`
Visibility string `json:"visibility"`
CreatedBy pgtype.UUID `json:"created_by"`
Enabled bool `json:"enabled"`
}
// 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.
func (q *Queries) CreateRuntimeProfile(ctx context.Context, arg CreateRuntimeProfileParams) (RuntimeProfile, error) {
row := q.db.QueryRow(ctx, createRuntimeProfile,
arg.WorkspaceID,
arg.DisplayName,
arg.ProtocolFamily,
arg.CommandName,
arg.Description,
arg.FixedArgs,
arg.Visibility,
arg.CreatedBy,
arg.Enabled,
)
var i RuntimeProfile
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAgentRuntimesByProfile = `-- name: DeleteAgentRuntimesByProfile :many
DELETE FROM agent_runtime
WHERE profile_id = $1
RETURNING id, workspace_id, owner_id, daemon_id, provider
`
type DeleteAgentRuntimesByProfileRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
DaemonID pgtype.Text `json:"daemon_id"`
Provider string `json:"provider"`
}
// 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.
func (q *Queries) DeleteAgentRuntimesByProfile(ctx context.Context, profileID pgtype.UUID) ([]DeleteAgentRuntimesByProfileRow, error) {
rows, err := q.db.Query(ctx, deleteAgentRuntimesByProfile, profileID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DeleteAgentRuntimesByProfileRow{}
for rows.Next() {
var i DeleteAgentRuntimesByProfileRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.OwnerID,
&i.DaemonID,
&i.Provider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteRuntimeProfile = `-- name: DeleteRuntimeProfile :exec
DELETE FROM runtime_profile
WHERE id = $1 AND workspace_id = $2
`
type DeleteRuntimeProfileParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteRuntimeProfile(ctx context.Context, arg DeleteRuntimeProfileParams) error {
_, err := q.db.Exec(ctx, deleteRuntimeProfile, arg.ID, arg.WorkspaceID)
return err
}
const getRuntimeProfile = `-- name: GetRuntimeProfile :one
SELECT id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at FROM runtime_profile
WHERE id = $1
`
func (q *Queries) GetRuntimeProfile(ctx context.Context, id pgtype.UUID) (RuntimeProfile, error) {
row := q.db.QueryRow(ctx, getRuntimeProfile, id)
var i RuntimeProfile
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRuntimeProfileForWorkspace = `-- name: GetRuntimeProfileForWorkspace :one
SELECT id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at FROM runtime_profile
WHERE id = $1 AND workspace_id = $2
`
type GetRuntimeProfileForWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetRuntimeProfileForWorkspace(ctx context.Context, arg GetRuntimeProfileForWorkspaceParams) (RuntimeProfile, error) {
row := q.db.QueryRow(ctx, getRuntimeProfileForWorkspace, arg.ID, arg.WorkspaceID)
var i RuntimeProfile
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAgentRuntimeIDsByProfile = `-- name: ListAgentRuntimeIDsByProfile :many
SELECT id FROM agent_runtime
WHERE profile_id = $1
`
// 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.
func (q *Queries) ListAgentRuntimeIDsByProfile(ctx context.Context, profileID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listAgentRuntimeIDsByProfile, profileID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEnabledRuntimeProfilesForWorkspace = `-- name: ListEnabledRuntimeProfilesForWorkspace :many
SELECT id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at FROM runtime_profile
WHERE workspace_id = $1 AND enabled = true
ORDER BY created_at ASC
`
// Daemon-facing list: only enabled profiles are candidates for a daemon to
// resolve on PATH and register. Ordered for stable output.
func (q *Queries) ListEnabledRuntimeProfilesForWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]RuntimeProfile, error) {
rows, err := q.db.Query(ctx, listEnabledRuntimeProfilesForWorkspace, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []RuntimeProfile{}
for rows.Next() {
var i RuntimeProfile
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRuntimeProfiles = `-- name: ListRuntimeProfiles :many
SELECT id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at FROM runtime_profile
WHERE workspace_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListRuntimeProfiles(ctx context.Context, workspaceID pgtype.UUID) ([]RuntimeProfile, error) {
rows, err := q.db.Query(ctx, listRuntimeProfiles, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []RuntimeProfile{}
for rows.Next() {
var i RuntimeProfile
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateRuntimeProfile = `-- name: UpdateRuntimeProfile :one
UPDATE runtime_profile
SET display_name = COALESCE($1, display_name),
command_name = COALESCE($2, command_name),
description = COALESCE($3, description),
fixed_args = COALESCE($4, fixed_args),
visibility = COALESCE($5, visibility),
enabled = COALESCE($6, enabled),
updated_at = now()
WHERE id = $7 AND workspace_id = $8
RETURNING id, workspace_id, display_name, protocol_family, command_name, description, fixed_args, visibility, created_by, enabled, created_at, updated_at
`
type UpdateRuntimeProfileParams struct {
DisplayName pgtype.Text `json:"display_name"`
CommandName pgtype.Text `json:"command_name"`
Description pgtype.Text `json:"description"`
FixedArgs []byte `json:"fixed_args"`
Visibility pgtype.Text `json:"visibility"`
Enabled pgtype.Bool `json:"enabled"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// 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.
func (q *Queries) UpdateRuntimeProfile(ctx context.Context, arg UpdateRuntimeProfileParams) (RuntimeProfile, error) {
row := q.db.QueryRow(ctx, updateRuntimeProfile,
arg.DisplayName,
arg.CommandName,
arg.Description,
arg.FixedArgs,
arg.Visibility,
arg.Enabled,
arg.ID,
arg.WorkspaceID,
)
var i RuntimeProfile
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.DisplayName,
&i.ProtocolFamily,
&i.CommandName,
&i.Description,
&i.FixedArgs,
&i.Visibility,
&i.CreatedBy,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -45,7 +45,11 @@ INSERT INTO agent_runtime (
owner_id,
last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (workspace_id, daemon_id, provider)
-- 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,
@@ -57,6 +61,40 @@ DO UPDATE SET
updated_at = now()
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 stays the protocol family so
-- task routing (agent.New(provider)) is unchanged; profile_id is the stable
-- identity. (xmax = 0) AS inserted mirrors UpsertAgentRuntime.
INSERT INTO agent_runtime (
workspace_id,
daemon_id,
name,
runtime_mode,
provider,
status,
device_info,
metadata,
owner_id,
profile_id,
last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 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()
RETURNING *, (xmax = 0) AS inserted;
-- name: UpdateAgentRuntimeVisibility :one
-- Toggles a runtime between 'private' (only owner can bind agents) and
-- 'public' (any workspace member can). Default for new rows is 'private'

View File

@@ -0,0 +1,83 @@
-- 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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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: 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
RETURNING id, workspace_id, owner_id, daemon_id, provider;
-- name: CountAgentsByProfile :one
-- Counts 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.
SELECT count(*) FROM agent a
JOIN agent_runtime ar ON ar.id = a.runtime_id
WHERE ar.profile_id = $1 AND a.archived_at IS NULL;
-- 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;