mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4054499227 | ||
|
|
8556c96b8c |
@@ -49,6 +49,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -661,7 +661,7 @@ export class ApiClient {
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { timezone?: string },
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -93,6 +93,7 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -12,11 +12,12 @@ export function useDeleteRuntime(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// useUpdateRuntime patches editable fields on a runtime (currently the
|
||||
// reporting timezone). Invalidates the runtime list AND any keys downstream
|
||||
// of the updated runtime — usage queries are bucketed by tz on the server,
|
||||
// so a tz change must blow away cached usage rows or the chart would lie
|
||||
// for one polling cycle.
|
||||
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
|
||||
// Invalidates the runtime list AND any keys downstream of the updated runtime
|
||||
// — usage queries are bucketed by tz on the server, so a tz change must blow
|
||||
// away cached usage rows or the chart would lie for one polling cycle. A
|
||||
// visibility change only needs the runtime list to refetch so the picker
|
||||
// disabled-state recomputes.
|
||||
export function useUpdateRuntime(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -25,11 +26,11 @@ export function useUpdateRuntime(wsId: string) {
|
||||
patch,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
patch: { timezone?: string };
|
||||
patch: { timezone?: string; visibility?: "private" | "public" };
|
||||
}) => api.updateRuntime(runtimeId, patch),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
if (vars) {
|
||||
if (vars && vars.patch.timezone !== undefined) {
|
||||
// Usage query keys are not workspace-scoped; invalidate only this
|
||||
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
|
||||
qc.invalidateQueries({
|
||||
|
||||
@@ -4,6 +4,14 @@ export type AgentRuntimeMode = "local" | "cloud";
|
||||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
// Runtime visibility is a separate axis from agent visibility — different
|
||||
// vocabulary because it gates a different action. "private" (default) means
|
||||
// only the runtime owner and workspace admins can bind agents to it;
|
||||
// "public" opens binding to any workspace member. Older backends that
|
||||
// haven't shipped MUL-2062 omit the field; the consumer must default to
|
||||
// "private" so the strictest behavior is the fallback.
|
||||
export type RuntimeVisibility = "private" | "public";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -16,6 +24,8 @@ export interface RuntimeDevice {
|
||||
device_info: string;
|
||||
metadata: Record<string, unknown>;
|
||||
owner_id: string | null;
|
||||
/** Defaults to "private" when the backend predates the visibility flag. */
|
||||
visibility: RuntimeVisibility;
|
||||
timezone: string;
|
||||
last_seen_at: string | null;
|
||||
created_at: string;
|
||||
|
||||
266
packages/views/agents/components/create-agent-dialog.test.tsx
Normal file
266
packages/views/agents/components/create-agent-dialog.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// ModelDropdown talks to the api; the create dialog only needs it as a
|
||||
// stand-in here, so swap it out.
|
||||
vi.mock("./model-dropdown", () => ({
|
||||
ModelDropdown: () => null,
|
||||
}));
|
||||
|
||||
// Provider logos don't matter for these assertions but they pull in SVGs.
|
||||
vi.mock("../../runtimes/components/provider-logo", () => ({
|
||||
ProviderLogo: () => null,
|
||||
}));
|
||||
|
||||
// Avatars hit the api for member metadata.
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
|
||||
const ME = "user-me";
|
||||
const OTHER = "user-other";
|
||||
|
||||
const members: MemberWithUser[] = [
|
||||
{
|
||||
id: "m-me",
|
||||
user_id: ME,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Me",
|
||||
email: "me@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "m-other",
|
||||
user_id: OTHER,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
return {
|
||||
id: "rt",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTemplate(runtimeId: string): Agent {
|
||||
return {
|
||||
id: "agent-template",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: runtimeId,
|
||||
name: "Template Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "private",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: ME,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { onCreate, onClose };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("disables another member's private runtime in the picker", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([mine, othersPrivate]);
|
||||
|
||||
// Flip to "All" so other-owned runtimes show.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
// Open the picker.
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const disabledRow = screen
|
||||
.getByText("Others Private")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(disabledRow).not.toBeNull();
|
||||
expect(disabledRow.disabled).toBe(true);
|
||||
expect(disabledRow.title).toMatch(/Private runtime/i);
|
||||
});
|
||||
|
||||
it("lets a plain member pick another member's public runtime", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPublic = makeRuntime({
|
||||
id: "rt-others-public",
|
||||
name: "Others Public",
|
||||
owner_id: OTHER,
|
||||
visibility: "public",
|
||||
});
|
||||
renderDialog([mine, othersPublic]);
|
||||
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const publicRow = screen
|
||||
.getByText("Others Public")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(publicRow).not.toBeNull();
|
||||
expect(publicRow.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults the selected runtime to a usable one, not a locked private", () => {
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([othersPrivate, mine]);
|
||||
|
||||
// The trigger label shows the selected runtime name. The picker must
|
||||
// not seed with the other-owned private runtime even if it sorted
|
||||
// first in the input list.
|
||||
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
|
||||
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
|
||||
// Template runtime is owned by someone else and now private — the
|
||||
// duplicate flow used to seed with it anyway, leaving the user with
|
||||
// a Create button that 403s server-side. Now we fall back to the
|
||||
// first usable runtime instead.
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
const template = makeTemplate("rt-others-private");
|
||||
const { onCreate } = renderDialog([othersPrivate, mine], template);
|
||||
|
||||
expect(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Others Private", { selector: "span.truncate" }),
|
||||
).toBeNull();
|
||||
|
||||
// Sanity check: with a usable selection seeded, Create should submit.
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
|
||||
});
|
||||
|
||||
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
|
||||
// Edge case: template points at a locked runtime AND the workspace
|
||||
// has no usable alternatives in scope. The defense-in-depth gate on
|
||||
// the Create button must keep the user from submitting a 403.
|
||||
const onlyOthersPrivate = makeRuntime({
|
||||
id: "rt-only-others-private",
|
||||
name: "Only Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
// Flip the picker to "All" so the locked runtime is at least
|
||||
// visible — that's the scope where the selected-but-locked state
|
||||
// can persist after the initial seed search returns nothing.
|
||||
const template = makeTemplate("rt-only-others-private");
|
||||
renderDialog([onlyOthersPrivate], template);
|
||||
|
||||
// The Create button is rendered by lucide-free CTA text "Create".
|
||||
const createBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent === "Create");
|
||||
expect(createBtn).toBeDefined();
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -85,33 +85,76 @@ export function CreateAgentDialog({
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
// A runtime is disabled for the caller when it's owned by someone else
|
||||
// AND its visibility is not "public". Older backends that haven't shipped
|
||||
// MUL-2062 leave visibility undefined; we treat anything other than the
|
||||
// literal string "public" as private so the strict default holds (the
|
||||
// backend will reject the create anyway).
|
||||
const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
|
||||
const filteredRuntimes = useMemo(() => {
|
||||
const filtered = runtimeFilter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
|
||||
// Caller's own runtimes first; among the rest, usable (public) ones
|
||||
// come before unusable (private) ones so the picker doesn't lead
|
||||
// with greyed-out rows.
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isRuntimeDisabledForUser(a);
|
||||
const bDisabled = isRuntimeDisabledForUser(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// currentUserId is the only external dep of isRuntimeDisabledForUser;
|
||||
// listing it in the deps array is enough.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, runtimeFilter, currentUserId]);
|
||||
|
||||
// When duplicating, default to the template's runtime so the clone
|
||||
// lands on the same machine — caller can still switch in the picker.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(
|
||||
template?.runtime_id ?? filteredRuntimes[0]?.id ?? "",
|
||||
);
|
||||
// But never seed with a runtime the caller can't actually use (locked
|
||||
// by visibility); otherwise the dialog opens with a selected row the
|
||||
// user can't submit, and Create falls through to a backend 403. Falling
|
||||
// back to the first usable runtime is friendlier than the locked
|
||||
// pre-fill.
|
||||
const templateRuntime = template?.runtime_id
|
||||
? runtimes.find((r) => r.id === template.runtime_id)
|
||||
: undefined;
|
||||
const initialRuntime =
|
||||
templateRuntime && !isRuntimeDisabledForUser(templateRuntime)
|
||||
? templateRuntime.id
|
||||
: filteredRuntimes.find((r) => !isRuntimeDisabledForUser(r))?.id ?? "";
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntime);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId && filteredRuntimes[0]) {
|
||||
setSelectedRuntimeId(filteredRuntimes[0].id);
|
||||
if (!selectedRuntimeId) {
|
||||
const firstUsable = filteredRuntimes.find(
|
||||
(r) => !isRuntimeDisabledForUser(r),
|
||||
);
|
||||
if (firstUsable) setSelectedRuntimeId(firstUsable.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filteredRuntimes, selectedRuntimeId]);
|
||||
|
||||
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
// Defense-in-depth: even if a locked runtime somehow ends up selected
|
||||
// (e.g. duplicate of an agent whose template runtime is now locked, and
|
||||
// the workspace has no usable fallback), gate Create on it so we don't
|
||||
// submit a request the backend will reject with 403.
|
||||
const selectedRuntimeLocked =
|
||||
selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !selectedRuntime) return;
|
||||
if (!name.trim() || !selectedRuntime || selectedRuntimeLocked) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
// When duplicating, forward the hidden config fields the template
|
||||
@@ -302,15 +345,33 @@ export function CreateAgentDialog({
|
||||
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
|
||||
{filteredRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
const disabled = isRuntimeDisabledForUser(device);
|
||||
// Use the native title for the disabled tooltip so we
|
||||
// don't have to wrap each row in a Tooltip primitive
|
||||
// inside a Popover (which has its own focus trap and
|
||||
// close-on-outside-click handling — adding another layer
|
||||
// makes the Popover dismiss when the tooltip portal
|
||||
// mounts).
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: device.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
@@ -322,6 +383,12 @@ export function CreateAgentDialog({
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{disabled && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
@@ -361,7 +428,14 @@ export function CreateAgentDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={creating || !name.trim() || !selectedRuntime}
|
||||
disabled={
|
||||
creating || !name.trim() || !selectedRuntime || selectedRuntimeLocked
|
||||
}
|
||||
title={
|
||||
selectedRuntimeLocked
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{creating ? t(($) => $.create_dialog.creating) : t(($) => $.create_dialog.create)}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cloud, Monitor } from "lucide-react";
|
||||
import { Cloud, Lock, Monitor } from "lucide-react";
|
||||
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
@@ -45,18 +45,28 @@ export function RuntimePicker({
|
||||
|
||||
// Compute filtered list unconditionally — the early `!canEdit` return
|
||||
// below would otherwise re-order this hook across renders.
|
||||
const isDisabled = (r: AgentRuntime): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
const filtered = useMemo(() => {
|
||||
const list =
|
||||
filter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId)
|
||||
return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId)
|
||||
return 1;
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isDisabled(a);
|
||||
const bDisabled = isDisabled(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
|
||||
if (!canEdit) {
|
||||
@@ -157,10 +167,12 @@ export function RuntimePicker({
|
||||
filtered.map((rt) => {
|
||||
const owner = getOwner(rt.owner_id);
|
||||
const rtOnline = rt.status === "online";
|
||||
const locked = isDisabled(rt);
|
||||
const tooltip = [
|
||||
rt.name,
|
||||
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
||||
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -168,7 +180,11 @@ export function RuntimePicker({
|
||||
<PickerItem
|
||||
key={rt.id}
|
||||
selected={rt.id === value}
|
||||
onClick={() => void select(rt.id)}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (locked) return;
|
||||
void select(rt.id);
|
||||
}}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<ProviderLogo
|
||||
@@ -185,6 +201,12 @@ export function RuntimePicker({
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{owner && (
|
||||
|
||||
@@ -212,6 +212,8 @@
|
||||
"runtime_none": "No runtime available",
|
||||
"runtime_register_first": "Register a runtime before creating an agent",
|
||||
"runtime_cloud_badge": "Cloud",
|
||||
"runtime_private_badge": "Private",
|
||||
"runtime_private_locked_tooltip": "Private runtime — only its owner or a workspace admin can create agents on it. Ask the owner to switch it to Public to share.",
|
||||
"duplicate_copy_suffix": " (Copy)",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
|
||||
@@ -64,7 +64,18 @@
|
||||
"no_agents": "No agents are bound to this runtime yet.",
|
||||
"diagnostics_title": "Diagnostics",
|
||||
"diagnostics_cli": "CLI",
|
||||
"diagnostics_visibility": "Visibility",
|
||||
"diagnostics_timezone": "Timezone",
|
||||
"visibility_label": {
|
||||
"private": "Private",
|
||||
"public": "Public"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "Only you and workspace admins can create agents on this runtime.",
|
||||
"public": "Any workspace member can create agents on this runtime."
|
||||
},
|
||||
"visibility_toast_updated": "Visibility set to {{visibility}}",
|
||||
"visibility_toast_failed": "Failed to update visibility",
|
||||
"timezone_browser_suffix": " (browser)",
|
||||
"timezone_hint": "Token-usage charts on this runtime bucket dates by this timezone.",
|
||||
"timezone_toast_updated": "Timezone updated to {{tz}}",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"runtime_none": "暂无可用运行时",
|
||||
"runtime_register_first": "请先注册一个运行时再创建智能体",
|
||||
"runtime_cloud_badge": "云端",
|
||||
"runtime_private_badge": "私有",
|
||||
"runtime_private_locked_tooltip": "私有运行时——只有 runtime 所有者或工作区管理员可在其上创建智能体。如果需要共享,请联系所有者切换为「公开」。",
|
||||
"duplicate_copy_suffix": "(副本)",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
|
||||
@@ -63,7 +63,18 @@
|
||||
"no_agents": "还没有智能体绑定到这个运行时。",
|
||||
"diagnostics_title": "诊断",
|
||||
"diagnostics_cli": "CLI",
|
||||
"diagnostics_visibility": "可见性",
|
||||
"diagnostics_timezone": "时区",
|
||||
"visibility_label": {
|
||||
"private": "私有",
|
||||
"public": "公开"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "仅你和工作区管理员可以在此运行时上创建智能体。",
|
||||
"public": "工作区内任何成员都可以在此运行时上创建智能体。"
|
||||
},
|
||||
"visibility_toast_updated": "可见性已设为「{{visibility}}」",
|
||||
"visibility_toast_failed": "更新可见性失败",
|
||||
"timezone_browser_suffix": "(浏览器)",
|
||||
"timezone_hint": "该 Runtime 的 Token 用量图表会按此时区进行日期分桶。",
|
||||
"timezone_toast_updated": "时区已更新为 {{tz}}",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Globe,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -248,11 +250,46 @@ function RuntimeNameCell({ runtime }: { runtime: AgentRuntime }) {
|
||||
<TooltipContent>{hostname}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<VisibilityBadge runtime={runtime} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VisibilityBadge — small chip next to the runtime name showing whether
|
||||
// the runtime is shareable (public) or owner-only (private). Older backends
|
||||
// that don't ship the visibility field render the strict default (private).
|
||||
function VisibilityBadge({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const isPublic = runtime.visibility === "public";
|
||||
const Icon = isPublic ? Globe : Lock;
|
||||
const label = isPublic
|
||||
? t(($) => $.detail.visibility_label.public)
|
||||
: t(($) => $.detail.visibility_label.private);
|
||||
const tooltip = isPublic
|
||||
? t(($) => $.detail.visibility_hint.public)
|
||||
: t(($) => $.detail.visibility_hint.private);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center gap-0.5 rounded px-1 text-[10px] font-medium ${
|
||||
isPublic
|
||||
? "bg-info/10 text-info"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthCell({
|
||||
runtime,
|
||||
now,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enRuntimes from "../../locales/en/runtimes.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, runtimes: enRuntimes, agents: enAgents },
|
||||
};
|
||||
|
||||
const mockUpdateRuntime = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
updateRuntime: (...args: unknown[]) => mockUpdateRuntime(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
// Pull the bits we want to test directly from the detail file. They aren't
|
||||
// exported, so we exercise them through RuntimeDetail's DiagnosticsCard.
|
||||
// Easier path: import the inner components by re-exporting them from a
|
||||
// shared module. They live in the same file as RuntimeDetail; rather than
|
||||
// touching the prod file just to ease testing, we test by rendering
|
||||
// `RuntimeDetail` with a runtime fixture and asserting on the visibility
|
||||
// UI. To avoid pulling in the entire detail page (which would need
|
||||
// presence maps, member lists, paths, agents queries, etc.) we stub the
|
||||
// heavy queries below.
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: (sel: (s: { user: { id: string } }) => unknown) =>
|
||||
sel({ user: { id: "user-me" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
deriveRuntimeHealth: () => "online",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
useWorkspacePresenceMap: () => ({ byAgent: new Map() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({
|
||||
runtimes: () => "/runtimes",
|
||||
agentDetail: () => "/agents",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/runtimes/mutations", () => ({
|
||||
useUpdateRuntime: () => ({
|
||||
mutate: (
|
||||
args: { runtimeId: string; patch: Record<string, unknown> },
|
||||
opts?: { onSuccess?: () => void; onError?: () => void },
|
||||
) => {
|
||||
mockUpdateRuntime(args.runtimeId, args.patch);
|
||||
opts?.onSuccess?.();
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// Stubbing ProviderLogo / UsageSection / UpdateSection avoids dragging in
|
||||
// chart libs and additional query keys we don't care about here.
|
||||
vi.mock("./provider-logo", () => ({ ProviderLogo: () => null }));
|
||||
vi.mock("./update-section", () => ({ UpdateSection: () => null }));
|
||||
vi.mock("./usage-section", () => ({ UsageSection: () => null }));
|
||||
vi.mock("./shared", () => ({ HealthBadge: () => null }));
|
||||
vi.mock("../../agents/presence", () => ({
|
||||
availabilityConfig: { offline: { dotClass: "", textClass: "" } },
|
||||
workloadConfig: { idle: { icon: () => null, textClass: "" } },
|
||||
}));
|
||||
vi.mock("../../common/actor-avatar", () => ({ ActorAvatar: () => null }));
|
||||
vi.mock("../../navigation", () => ({ AppLink: () => null }));
|
||||
|
||||
import { RuntimeDetail } from "./runtime-detail";
|
||||
|
||||
function makeRuntime(overrides: Partial<AgentRuntime>): AgentRuntime {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Local Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
owner_id: "user-me",
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDetail(runtime: AgentRuntime) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={qc}>
|
||||
<RuntimeDetail runtime={runtime} />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RuntimeDetail visibility section", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows owner-editable visibility choices when the caller owns the runtime", () => {
|
||||
renderDetail(makeRuntime({ owner_id: "user-me" }));
|
||||
expect(screen.getByText("Visibility")).toBeInTheDocument();
|
||||
expect(screen.getByText("Private")).toBeInTheDocument();
|
||||
expect(screen.getByText("Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flips visibility to public when the owner clicks the Public choice", async () => {
|
||||
renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" }));
|
||||
fireEvent.click(screen.getByText("Public"));
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { visibility: "public" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a read-only visibility chip when the caller cannot edit", () => {
|
||||
renderDetail(makeRuntime({ owner_id: "someone-else", visibility: "public" }));
|
||||
expect(screen.getByText("Public")).toBeInTheDocument();
|
||||
// The editor's "Private" choice button must not render in read-only mode.
|
||||
expect(screen.queryByText("Private")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Globe,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -506,6 +507,9 @@ function DiagnosticsCard({
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
const isLocal = runtime.runtime_mode === "local";
|
||||
// canDelete here doubles as the "can edit runtime" predicate — it already
|
||||
// means "workspace owner/admin OR runtime owner", which is the same gate
|
||||
// the server enforces for the visibility PATCH.
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<div className="border-b px-4 py-2.5">
|
||||
@@ -513,6 +517,16 @@ function DiagnosticsCard({
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{t(($) => $.detail.diagnostics_visibility)}
|
||||
</div>
|
||||
{canDelete ? (
|
||||
<VisibilityEditor runtime={runtime} />
|
||||
) : (
|
||||
<VisibilityReadout runtime={runtime} />
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{t(($) => $.detail.diagnostics_timezone)}
|
||||
</div>
|
||||
@@ -601,6 +615,115 @@ function supportedTimezones(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// VisibilityReadout renders a static "Private" / "Public" pill for users
|
||||
// who can't edit the runtime. Older backends that omit the field render as
|
||||
// "Private" to match the strict default.
|
||||
function VisibilityReadout({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const visibility = runtime.visibility === "public" ? "public" : "private";
|
||||
const Icon = visibility === "public" ? Globe : Lock;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2 py-1.5 text-xs">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t(($) => $.detail.visibility_label[visibility])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t(($) => $.detail.visibility_hint[visibility])}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VisibilityEditor lets the runtime owner (or workspace admin) flip
|
||||
// public↔private. The PATCH endpoint also re-checks; this is a UI gate, not
|
||||
// a security boundary.
|
||||
function VisibilityEditor({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const wsId = useWorkspaceId();
|
||||
const updateRuntime = useUpdateRuntime(wsId);
|
||||
const current = runtime.visibility === "public" ? "public" : "private";
|
||||
|
||||
const flip = (next: "private" | "public") => {
|
||||
if (next === current) return;
|
||||
updateRuntime.mutate(
|
||||
{ runtimeId: runtime.id, patch: { visibility: next } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
t(($) => $.detail.visibility_toast_updated, {
|
||||
visibility: t(($) => $.detail.visibility_label[next]),
|
||||
}),
|
||||
),
|
||||
onError: () =>
|
||||
toast.error(t(($) => $.detail.visibility_toast_failed)),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<VisibilityChoice
|
||||
active={current === "private"}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
label={t(($) => $.detail.visibility_label.private)}
|
||||
hint={t(($) => $.detail.visibility_hint.private)}
|
||||
disabled={updateRuntime.isPending}
|
||||
onClick={() => flip("private")}
|
||||
/>
|
||||
<VisibilityChoice
|
||||
active={current === "public"}
|
||||
icon={<Globe className="h-3.5 w-3.5" />}
|
||||
label={t(($) => $.detail.visibility_label.public)}
|
||||
hint={t(($) => $.detail.visibility_hint.public)}
|
||||
disabled={updateRuntime.isPending}
|
||||
onClick={() => flip("public")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibilityChoice({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex flex-1 items-start gap-2 rounded-md border px-2.5 py-2 text-left text-xs transition-colors ${
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted"
|
||||
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{label}</span>
|
||||
<span className="block text-[11px] leading-snug text-muted-foreground">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimezoneReadout({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
return (
|
||||
|
||||
@@ -458,6 +458,15 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
// Probe workspace agent count BEFORE the insert so the funnel has a
|
||||
// clean "first agent ever in this workspace" signal — Step 4 of
|
||||
// onboarding always lands in this branch. A non-fatal read: if the
|
||||
@@ -673,6 +682,17 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
// Same gate as CreateAgent — prevents UpdateAgent from being used to
|
||||
// re-bind an agent onto someone else's private runtime, which would
|
||||
// otherwise be a quiet end-run around the CreateAgent check.
|
||||
member, ok := h.workspaceMember(w, r, uuidToString(agent.WorkspaceID))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can move agents onto it")
|
||||
return
|
||||
}
|
||||
params.RuntimeID = runtime.ID
|
||||
params.RuntimeMode = pgtype.Text{String: runtime.RuntimeMode, Valid: true}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ type AgentRuntimeResponse struct {
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Timezone string `json:"timezone"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// Visibility is "private" (default — only the owner / workspace admins
|
||||
// can bind agents) or "public" (any workspace member can). See migration
|
||||
// 083 and canUseRuntimeForAgent.
|
||||
Visibility string `json:"visibility"`
|
||||
Timezone string `json:"timezone"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
@@ -54,6 +58,7 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
DeviceInfo: rt.DeviceInfo,
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
Visibility: rt.Visibility,
|
||||
Timezone: rt.Timezone,
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
@@ -437,6 +442,10 @@ type UpdateAgentRuntimeRequest struct {
|
||||
// Timezone is an IANA zone name (e.g. "Asia/Shanghai", "America/New_York").
|
||||
// Validated server-side via time.LoadLocation; "UTC" or empty resets to UTC.
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
// Visibility flips a runtime between "private" (default — only the owner
|
||||
// or workspace admins can bind agents) and "public" (any workspace
|
||||
// member can). Owner / workspace admin only, gated by canEditRuntime.
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAgentRuntime handles PATCH /api/runtimes/:id. Currently only the
|
||||
@@ -472,6 +481,21 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate every field that's present BEFORE running any mutation. A
|
||||
// PATCH that carries both `timezone` and `visibility` must succeed or
|
||||
// fail atomically from the caller's perspective: writing timezone first
|
||||
// and then 400-ing on a bad visibility would leave the row half-updated
|
||||
// (and the usage rollup rebuilt under a tz the caller never asked for).
|
||||
//
|
||||
// This loop also fixes the no-op short-circuit: the prior version
|
||||
// returned early when `timezone == rt.Timezone`, silently dropping a
|
||||
// concurrent visibility patch in the same request body.
|
||||
var (
|
||||
newTimezone string
|
||||
needTimezone bool
|
||||
newVisibility string
|
||||
needVisibility bool
|
||||
)
|
||||
if req.Timezone != nil {
|
||||
tz := *req.Timezone
|
||||
if tz == "" {
|
||||
@@ -481,12 +505,24 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid IANA timezone")
|
||||
return
|
||||
}
|
||||
|
||||
if tz == rt.Timezone {
|
||||
writeJSON(w, http.StatusOK, runtimeToResponse(rt))
|
||||
if tz != rt.Timezone {
|
||||
newTimezone = tz
|
||||
needTimezone = true
|
||||
}
|
||||
}
|
||||
if req.Visibility != nil {
|
||||
v := *req.Visibility
|
||||
if v != "private" && v != "public" {
|
||||
writeError(w, http.StatusBadRequest, "visibility must be 'private' or 'public'")
|
||||
return
|
||||
}
|
||||
if v != rt.Visibility {
|
||||
newVisibility = v
|
||||
needVisibility = true
|
||||
}
|
||||
}
|
||||
|
||||
if needTimezone {
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runtime")
|
||||
@@ -502,7 +538,7 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
updated, err := qtx.UpdateAgentRuntimeTimezone(r.Context(), db.UpdateAgentRuntimeTimezoneParams{
|
||||
ID: runtimeUUID,
|
||||
Timezone: tz,
|
||||
Timezone: newTimezone,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("UpdateAgentRuntimeTimezone failed", "error", err, "runtime_id", runtimeID)
|
||||
@@ -532,6 +568,25 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
rt = updated
|
||||
}
|
||||
|
||||
if needVisibility {
|
||||
updated, err := h.Queries.UpdateAgentRuntimeVisibility(r.Context(), db.UpdateAgentRuntimeVisibilityParams{
|
||||
ID: runtimeUUID,
|
||||
Visibility: newVisibility,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("UpdateAgentRuntimeVisibility failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runtime")
|
||||
return
|
||||
}
|
||||
rt = updated
|
||||
// Notify connected clients that runtime metadata changed so the
|
||||
// list/detail pages refresh — matches the pattern used by
|
||||
// DeleteAgentRuntime.
|
||||
h.publish(protocol.EventDaemonRegister, uuidToString(rt.WorkspaceID), "member", uuidToString(member.UserID), map[string]any{
|
||||
"action": "update",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, runtimeToResponse(rt))
|
||||
}
|
||||
|
||||
@@ -542,6 +597,23 @@ func canEditRuntime(member db.Member, rt db.AgentRuntime) bool {
|
||||
return rt.OwnerID.Valid && uuidToString(rt.OwnerID) == uuidToString(member.UserID)
|
||||
}
|
||||
|
||||
// canUseRuntimeForAgent reports whether a workspace member is allowed to
|
||||
// bind a new agent to — or move an existing agent onto — the given runtime.
|
||||
// Mirrors canEditRuntime but layers on the runtime's visibility flag so a
|
||||
// `public` runtime is usable by anyone in the workspace while a `private`
|
||||
// runtime stays bound to its owner. Workspace owners/admins keep an
|
||||
// administrative override for both. See migration 083 for the visibility
|
||||
// column.
|
||||
func canUseRuntimeForAgent(member db.Member, rt db.AgentRuntime) bool {
|
||||
if roleAllowed(member.Role, "owner", "admin") {
|
||||
return true
|
||||
}
|
||||
if rt.Visibility == "public" {
|
||||
return true
|
||||
}
|
||||
return rt.OwnerID.Valid && uuidToString(rt.OwnerID) == uuidToString(member.UserID)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
|
||||
|
||||
402
server/internal/handler/runtime_visibility_test.go
Normal file
402
server/internal/handler/runtime_visibility_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// TestCanUseRuntimeForAgent_Pure exercises the pure predicate behind the
|
||||
// CreateAgent / UpdateAgent runtime gate. The truth table mirrors the issue
|
||||
// (MUL-2062) acceptance criteria: workspace owner / admin can use any
|
||||
// runtime, runtime owners can use their own runtime regardless of
|
||||
// visibility, and any member can use a public runtime; everyone else gets
|
||||
// denied for a private runtime owned by someone else.
|
||||
func TestCanUseRuntimeForAgent_Pure(t *testing.T) {
|
||||
ownerUserID := "11111111-1111-1111-1111-111111111111"
|
||||
otherUserID := "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
privateRT := db.AgentRuntime{
|
||||
OwnerID: util.MustParseUUID(ownerUserID),
|
||||
Visibility: "private",
|
||||
}
|
||||
publicRT := db.AgentRuntime{
|
||||
OwnerID: util.MustParseUUID(ownerUserID),
|
||||
Visibility: "public",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
userID string
|
||||
role string
|
||||
rt db.AgentRuntime
|
||||
want bool
|
||||
}{
|
||||
// workspace owner / admin override
|
||||
{"workspace owner on private runtime owned by another", otherUserID, "owner", privateRT, true},
|
||||
{"workspace admin on private runtime owned by another", otherUserID, "admin", privateRT, true},
|
||||
// runtime owner
|
||||
{"runtime owner on own private runtime", ownerUserID, "member", privateRT, true},
|
||||
{"runtime owner on own public runtime", ownerUserID, "member", publicRT, true},
|
||||
// public runtime allows anyone in workspace
|
||||
{"plain member on someone else's public runtime", otherUserID, "member", publicRT, true},
|
||||
// the hole the issue closes
|
||||
{"plain member on someone else's private runtime", otherUserID, "member", privateRT, false},
|
||||
{"plain member with empty role on private runtime", otherUserID, "", privateRT, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
member := db.Member{
|
||||
UserID: util.MustParseUUID(tc.userID),
|
||||
Role: tc.role,
|
||||
}
|
||||
got := canUseRuntimeForAgent(member, tc.rt)
|
||||
if got != tc.want {
|
||||
t.Fatalf("canUseRuntimeForAgent(role=%s, visibility=%s, owner=%s, caller=%s) = %v; want %v",
|
||||
tc.role, tc.rt.Visibility, ownerUserID, tc.userID, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeVisibilityFixture builds the three-actor world the gate needs to
|
||||
// exercise: a private runtime owned by a non-admin member, a separate plain
|
||||
// member in the same workspace, and the workspace owner (testUserID). The
|
||||
// runtime is registered through agent_runtime directly so the test doesn't
|
||||
// depend on the daemon-registration code path. Returns runtime id, runtime
|
||||
// owner user id, and the plain member's user id.
|
||||
func runtimeVisibilityFixture(t *testing.T) (runtimeID, runtimeOwnerID, plainMemberID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Runtime Owner', 'runtime-owner@multica.test')
|
||||
RETURNING id
|
||||
`).Scan(&runtimeOwnerID); err != nil {
|
||||
t.Fatalf("create runtime owner user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = 'runtime-owner@multica.test'`)
|
||||
})
|
||||
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, testWorkspaceID, runtimeOwnerID); err != nil {
|
||||
t.Fatalf("add runtime owner as member: %v", err)
|
||||
}
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Plain Runtime Member', 'plain-runtime-member@multica.test')
|
||||
RETURNING id
|
||||
`).Scan(&plainMemberID); err != nil {
|
||||
t.Fatalf("create plain member user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = 'plain-runtime-member@multica.test'`)
|
||||
})
|
||||
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, testWorkspaceID, plainMemberID); err != nil {
|
||||
t.Fatalf("add plain member: %v", err)
|
||||
}
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_runtime (
|
||||
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
||||
device_info, metadata, owner_id, visibility, last_seen_at
|
||||
)
|
||||
VALUES ($1, NULL, 'Visibility Test Runtime', 'cloud', 'visibility_test_provider', 'online', 'visibility test', '{}'::jsonb, $2, 'private', now())
|
||||
RETURNING id
|
||||
`, testWorkspaceID, runtimeOwnerID).Scan(&runtimeID); err != nil {
|
||||
t.Fatalf("create runtime: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
||||
})
|
||||
|
||||
return runtimeID, runtimeOwnerID, plainMemberID
|
||||
}
|
||||
|
||||
// TestCreateAgent_RejectsPrivateRuntimeForNonOwner walks the gate end-to-end:
|
||||
// the runtime is private and owned by a non-admin member, so a workspace
|
||||
// owner and the runtime owner can both create agents on it, but a plain
|
||||
// workspace member cannot.
|
||||
func TestCreateAgent_RejectsPrivateRuntimeForNonOwner(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID, runtimeOwnerID, plainMemberID := runtimeVisibilityFixture(t)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name LIKE 'runtime-visibility-test-%'`,
|
||||
testWorkspaceID)
|
||||
})
|
||||
|
||||
body := func(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"description": "",
|
||||
"runtime_id": runtimeID,
|
||||
"visibility": "private",
|
||||
"max_concurrent_tasks": 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace owner (testUserID): allowed via admin override even though
|
||||
// the runtime is private and owned by someone else.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body("runtime-visibility-test-admin")))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAgent as workspace owner: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Runtime owner: allowed because they own the runtime.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.CreateAgent(w, newRequestAs(runtimeOwnerID, http.MethodPost, "/api/agents", body("runtime-visibility-test-runtime-owner")))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAgent as runtime owner: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Plain member: this is the hole MUL-2062 closes — must be 403.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.CreateAgent(w, newRequestAs(plainMemberID, http.MethodPost, "/api/agents", body("runtime-visibility-test-plain-member")))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("CreateAgent as plain member on private runtime: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateAgent_AllowsPublicRuntimeForPlainMember verifies the "public"
|
||||
// half of the visibility predicate: once the runtime owner flips it to
|
||||
// public, any workspace member can create agents on it.
|
||||
func TestCreateAgent_AllowsPublicRuntimeForPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID, _, plainMemberID := runtimeVisibilityFixture(t)
|
||||
ctx := context.Background()
|
||||
if _, err := testPool.Exec(ctx,
|
||||
`UPDATE agent_runtime SET visibility = 'public' WHERE id = $1`, runtimeID,
|
||||
); err != nil {
|
||||
t.Fatalf("flip runtime to public: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = 'runtime-visibility-test-public-runtime'`,
|
||||
testWorkspaceID)
|
||||
})
|
||||
|
||||
body := map[string]any{
|
||||
"name": "runtime-visibility-test-public-runtime",
|
||||
"description": "",
|
||||
"runtime_id": runtimeID,
|
||||
"visibility": "private",
|
||||
"max_concurrent_tasks": 1,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateAgent(w, newRequestAs(plainMemberID, http.MethodPost, "/api/agents", body))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAgent as plain member on public runtime: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAgent_RejectsRebindToPrivateRuntime is the regression for the
|
||||
// "update can bypass create" backdoor — without this gate a plain member
|
||||
// could create an agent on a public runtime, then re-bind it onto someone
|
||||
// else's private runtime via UpdateAgent.
|
||||
func TestUpdateAgent_RejectsRebindToPrivateRuntime(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
privateRuntimeID, _, plainMemberID := runtimeVisibilityFixture(t)
|
||||
|
||||
ctx := context.Background()
|
||||
// Create a public runtime that the plain member can legitimately own
|
||||
// an agent on, then we try to move the agent onto the private runtime.
|
||||
var publicRuntimeID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_runtime (
|
||||
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
||||
device_info, metadata, owner_id, visibility, last_seen_at
|
||||
)
|
||||
VALUES ($1, NULL, 'Public Runtime', 'cloud', 'visibility_test_public_provider', 'online', 'public', '{}'::jsonb, $2, 'public', now())
|
||||
RETURNING id
|
||||
`, testWorkspaceID, plainMemberID).Scan(&publicRuntimeID); err != nil {
|
||||
t.Fatalf("create public runtime: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, publicRuntimeID)
|
||||
})
|
||||
|
||||
var agentID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
)
|
||||
VALUES ($1, 'rebind-test-agent', '', 'cloud', '{}'::jsonb,
|
||||
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, publicRuntimeID, plainMemberID).Scan(&agentID); err != nil {
|
||||
t.Fatalf("create agent on public runtime: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
||||
})
|
||||
|
||||
body := map[string]any{
|
||||
"runtime_id": privateRuntimeID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(plainMemberID, http.MethodPut, "/api/agents/"+agentID, body)
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("UpdateAgent rebinding to private runtime: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAgentRuntime_CombinedPatchAppliesBoth pins the post-review
|
||||
// invariant that a PATCH carrying BOTH `timezone` and `visibility` runs
|
||||
// both mutations. Before the fix, the timezone branch returned early on
|
||||
// a tz no-op (`tz == rt.Timezone`) and the visibility branch was never
|
||||
// reached, silently dropping half of a legitimate request.
|
||||
func TestUpdateAgentRuntime_CombinedPatchAppliesBoth(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
|
||||
|
||||
// Use the runtime's CURRENT timezone (UTC by default) so the timezone
|
||||
// branch is a no-op — that's the exact case the old short-circuit hit.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(runtimeOwnerID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
|
||||
"timezone": "UTC",
|
||||
"visibility": "public",
|
||||
})
|
||||
req = withURLParam(req, "runtimeId", runtimeID)
|
||||
testHandler.UpdateAgentRuntime(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH timezone+visibility (tz no-op): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp AgentRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Visibility != "public" {
|
||||
t.Fatalf("combined patch dropped visibility: got %q, want public", resp.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone pins the
|
||||
// other half of the ordering fix: when visibility is invalid the handler
|
||||
// must 400 BEFORE running the timezone mutation, so a malformed request
|
||||
// can't leave the row half-updated (timezone written, visibility rejected).
|
||||
func TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
|
||||
|
||||
ctx := context.Background()
|
||||
// Snapshot the timezone before the request so we can assert it didn't
|
||||
// move. The fixture inserts the runtime with the table default (UTC),
|
||||
// but we read instead of hard-coding so future fixture changes don't
|
||||
// silently make the test pass for the wrong reason.
|
||||
var beforeTZ string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT timezone FROM agent_runtime WHERE id = $1`, runtimeID,
|
||||
).Scan(&beforeTZ); err != nil {
|
||||
t.Fatalf("snapshot timezone: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(runtimeOwnerID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
|
||||
"timezone": "Asia/Tokyo",
|
||||
"visibility": "everyone",
|
||||
})
|
||||
req = withURLParam(req, "runtimeId", runtimeID)
|
||||
testHandler.UpdateAgentRuntime(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("PATCH with invalid visibility: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var afterTZ string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT timezone FROM agent_runtime WHERE id = $1`, runtimeID,
|
||||
).Scan(&afterTZ); err != nil {
|
||||
t.Fatalf("re-read timezone: %v", err)
|
||||
}
|
||||
if afterTZ != beforeTZ {
|
||||
t.Fatalf("400 on visibility must not mutate timezone: before=%q after=%q", beforeTZ, afterTZ)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAgentRuntime_VisibilityToggle covers the PATCH endpoint:
|
||||
// runtime owner / workspace admin can flip private↔public; plain members
|
||||
// cannot; an unknown value is rejected with 400.
|
||||
func TestUpdateAgentRuntime_VisibilityToggle(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID, runtimeOwnerID, plainMemberID := runtimeVisibilityFixture(t)
|
||||
|
||||
patch := func(actorID string, visibility string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, map[string]any{
|
||||
"visibility": visibility,
|
||||
})
|
||||
req = withURLParam(req, "runtimeId", runtimeID)
|
||||
testHandler.UpdateAgentRuntime(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// Runtime owner flips private → public.
|
||||
if w := patch(runtimeOwnerID, "public"); w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgentRuntime as runtime owner → public: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
} else {
|
||||
var resp AgentRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Visibility != "public" {
|
||||
t.Fatalf("expected visibility=public, got %q", resp.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace owner (testUserID) flips it back.
|
||||
if w := patch(testUserID, "private"); w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgentRuntime as workspace owner → private: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Plain member: forbidden, regardless of intent.
|
||||
if w := patch(plainMemberID, "public"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("UpdateAgentRuntime as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Bad value from the owner: 400.
|
||||
if w := patch(runtimeOwnerID, "everyone"); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("UpdateAgentRuntime with invalid visibility: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
1
server/migrations/083_runtime_visibility.down.sql
Normal file
1
server/migrations/083_runtime_visibility.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent_runtime DROP COLUMN IF EXISTS visibility;
|
||||
3
server/migrations/083_runtime_visibility.up.sql
Normal file
3
server/migrations/083_runtime_visibility.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE agent_runtime
|
||||
ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'public'));
|
||||
@@ -59,7 +59,8 @@ type AgentRuntime struct {
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
|
||||
// IANA timezone (e.g. 'Asia/Shanghai'). Bucket boundary for per-day and per-hour token usage aggregation. Defaults to UTC for runtimes that existed before MUL-1950; the daemon registration / web UI overwrites this with an operator-detected value going forward.
|
||||
Timezone string `json:"timezone"`
|
||||
Timezone string `json:"timezone"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type AgentSkill struct {
|
||||
|
||||
@@ -237,7 +237,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, timezone 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, timezone, visibility FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
AND provider = $2
|
||||
AND LOWER(daemon_id) = LOWER($3)
|
||||
@@ -289,6 +289,7 @@ func (q *Queries) FindLegacyRuntimesByDaemonID(ctx context.Context, arg FindLega
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -348,7 +349,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, timezone 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, timezone, visibility FROM agent_runtime
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -371,12 +372,13 @@ func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRun
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
)
|
||||
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, timezone 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, timezone, visibility FROM agent_runtime
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -404,6 +406,7 @@ func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentR
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -454,7 +457,7 @@ func (q *Queries) InsertTaskUsageDailyForRuntime(ctx context.Context, runtimeID
|
||||
}
|
||||
|
||||
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, timezone 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, timezone, visibility FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -484,6 +487,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -496,7 +500,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, timezone 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, timezone, visibility FROM agent_runtime
|
||||
WHERE workspace_id = $1 AND owner_id = $2
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -531,6 +535,7 @@ func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRun
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -558,7 +563,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, timezone
|
||||
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, timezone, visibility
|
||||
`
|
||||
|
||||
// Used on the offline→online transition (and on first heartbeat after
|
||||
@@ -583,6 +588,7 @@ func (q *Queries) MarkAgentRuntimeOnline(ctx context.Context, id pgtype.UUID) (A
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -816,7 +822,7 @@ const updateAgentRuntimeTimezone = `-- name: UpdateAgentRuntimeTimezone :one
|
||||
UPDATE agent_runtime
|
||||
SET timezone = $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, timezone
|
||||
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, timezone, visibility
|
||||
`
|
||||
|
||||
type UpdateAgentRuntimeTimezoneParams struct {
|
||||
@@ -844,6 +850,47 @@ func (q *Queries) UpdateAgentRuntimeTimezone(ctx context.Context, arg UpdateAgen
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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, timezone, visibility
|
||||
`
|
||||
|
||||
type UpdateAgentRuntimeVisibilityParams struct {
|
||||
Visibility string `json:"visibility"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
}
|
||||
|
||||
// Toggles a runtime between 'private' (only owner can bind agents) and
|
||||
// 'public' (any workspace member can). Default for new rows is 'private'
|
||||
// (see migration 083). Gated at the handler layer to owner / workspace
|
||||
// admin only.
|
||||
func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAgentRuntimeVisibilityParams) (AgentRuntime, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.ID)
|
||||
var i AgentRuntime
|
||||
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.Timezone,
|
||||
&i.Visibility,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -872,7 +919,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, timezone, (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, timezone, visibility, (xmax = 0) AS inserted
|
||||
`
|
||||
|
||||
type UpsertAgentRuntimeParams struct {
|
||||
@@ -904,6 +951,7 @@ type UpsertAgentRuntimeRow struct {
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
LegacyDaemonID pgtype.Text `json:"legacy_daemon_id"`
|
||||
Timezone string `json:"timezone"`
|
||||
Visibility string `json:"visibility"`
|
||||
Inserted bool `json:"inserted"`
|
||||
}
|
||||
|
||||
@@ -946,6 +994,7 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime
|
||||
&i.OwnerID,
|
||||
&i.LegacyDaemonID,
|
||||
&i.Timezone,
|
||||
&i.Visibility,
|
||||
&i.Inserted,
|
||||
)
|
||||
return i, err
|
||||
|
||||
@@ -59,6 +59,16 @@ SET timezone = @timezone, updated_at = now()
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
-- 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'
|
||||
-- (see migration 083). Gated at the handler layer to owner / workspace
|
||||
-- admin only.
|
||||
UPDATE agent_runtime
|
||||
SET visibility = @visibility, updated_at = now()
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteTaskUsageDailyForRuntime :execrows
|
||||
-- First step of an explicit user timezone edit rebuild. Delete old materialized
|
||||
-- rows before re-inserting under the runtime's new timezone.
|
||||
|
||||
Reference in New Issue
Block a user