mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 10:59:31 +02:00
Compare commits
9 Commits
fix/commen
...
test/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d6b7663df | ||
|
|
8b68ef508f | ||
|
|
a4102ac723 | ||
|
|
d655762e2f | ||
|
|
cadfae6ef1 | ||
|
|
077e40c638 | ||
|
|
1a285e94a5 | ||
|
|
84b32e35ff | ||
|
|
a86aecef80 |
@@ -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 },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./queries";
|
||||
export * from "./profiles";
|
||||
export * from "./mutations";
|
||||
export * from "./hooks";
|
||||
export * from "./models";
|
||||
|
||||
103
packages/core/runtimes/profiles.ts
Normal file
103
packages/core/runtimes/profiles.ts
Normal 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 };
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "期間",
|
||||
|
||||
@@ -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": "기간",
|
||||
|
||||
@@ -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": "时间范围",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
741
packages/views/runtimes/components/runtime-profiles-dialog.tsx
Normal file
741
packages/views/runtimes/components/runtime-profiles-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
368
server/cmd/multica/cmd_runtime_profile.go
Normal file
368
server/cmd/multica/cmd_runtime_profile.go
Normal 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)
|
||||
}
|
||||
360
server/cmd/multica/cmd_runtime_profile_test.go
Normal file
360
server/cmd/multica/cmd_runtime_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
353
server/internal/daemon/runtime_profile_test.go
Normal file
353
server/internal/daemon/runtime_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
477
server/internal/handler/runtime_profile.go
Normal file
477
server/internal/handler/runtime_profile.go
Normal 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,
|
||||
})
|
||||
}
|
||||
185
server/internal/handler/runtime_profile_handler_test.go
Normal file
185
server/internal/handler/runtime_profile_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
13
server/migrations/120_runtime_profile.down.sql
Normal file
13
server/migrations/120_runtime_profile.down.sql
Normal 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;
|
||||
83
server/migrations/120_runtime_profile.up.sql
Normal file
83
server/migrations/120_runtime_profile.up.sql
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
52
server/pkg/agent/agent_supported_types_test.go
Normal file
52
server/pkg/agent/agent_supported_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
370
server/pkg/db/generated/runtime_profile.sql.go
Normal file
370
server/pkg/db/generated/runtime_profile.sql.go
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
83
server/pkg/db/queries/runtime_profile.sql
Normal file
83
server/pkg/db/queries/runtime_profile.sql
Normal 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;
|
||||
Reference in New Issue
Block a user