mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
MUL-8185: Support sharing runtimes with specific members (#9455)
* feat: share runtimes with selected workspace members Co-authored-by: multica-agent <[email protected]> * refactor(runtimes): one-click private/public and popover member picker Private and Public apply immediately again (restrictions still go through the reviewed revocation dialog). Specific members opens a draft popover with search, Cancel and Save; selected recipients are summarized under the scope control with an Edit entry point. The editor moves to its own runtime-access-editor component. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
f9036be46a
commit
ff674c4e1d
@@ -109,13 +109,15 @@ One daemon executes at most 20 runs at a time by default; each agent at most 6.
|
||||
|
||||
Once a limit is reached, new runs keep queueing. You can adjust a single agent's concurrency in the agent settings, and the machine-wide cap through `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`. Parallel runs compete for machine capacity, tool account quota, and the same working directory all at once.
|
||||
|
||||
## Private and public runtimes
|
||||
## Runtime sharing
|
||||
|
||||
A local runtime is **private** by default: only the runtime owner can create agents on it. Workspace owners and admins are no exception — the runtime is someone else's computer, and running an agent there spends their machine and their tool credentials.
|
||||
|
||||
Only the runtime owner can make it **public** — workspace admins can rename or delete a runtime, but sharing one is the owner's decision. Other workspace members can then select this runtime as well; that does not share the underlying AI coding tool's login credentials — it only lets members route their agents' runs to this computer.
|
||||
|
||||
When the owner makes a public runtime **private** again, Multica shows the affected agents and active runs, including Agent Builder sessions, before applying the change. Confirmation unbinds those teammates' agents, cancels their queued, dispatched, running, local-directory-waiting and deferred runs with `runtime_access_revoked`, and pauses active autopilots assigned to the unbound agents. The owner's own agents and runs are unaffected. A changed plan requires renewed confirmation; concurrent chat writes may require a retry. To resume work, bind the agent to an accessible runtime, then explicitly resume its paused autopilots.
|
||||
To share with selected people, open the runtime's detail page, select **Specific members**, choose one or more workspace members, and **Save**. Those members can select the runtime and create agents on it. Only its owner can change the recipient list; recipients do not gain permission to rename, delete, or administer the runtime.
|
||||
|
||||
When the owner removes members, limits a public runtime to specific members, or makes it **private** again, Multica shows the affected agents and active runs, including Agent Builder sessions, before applying the change. Confirmation unbinds those teammates' agents, cancels their queued, dispatched, running, local-directory-waiting and deferred runs with `runtime_access_revoked`, and pauses active autopilots assigned to the unbound agents. Agents and runs belonging to the owner or members who retain access are unaffected. A changed plan requires renewed confirmation; concurrent chat writes may require a retry. To resume work, bind the agent to an accessible runtime, then explicitly resume its paused autopilots.
|
||||
|
||||
Cancellation preserves chat history and stores draft/attachment recovery data on the server. Web and desktop use the existing draft recovery UI; mobile does not yet expose that recovery UI. A started turn with no output waits for the daemon's cancellation acknowledgement or the server's finalize grace period before deciding whether to restore the input. A connected daemon observes cancellation through its regular check. If the computer is offline, server-side access is still revoked immediately, but the local process may continue until it receives cancellation after reconnecting; the server cannot guarantee an immediate remote stop.
|
||||
|
||||
|
||||
@@ -109,13 +109,15 @@ multica daemon start
|
||||
|
||||
达到上限后,新运行会继续排队。你可以在智能体设置中调整单个智能体的并发数,也可以通过 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 调整整台电脑的上限。并行执行会同时占用机器性能、工具账号额度和同一工作目录。
|
||||
|
||||
## 私有与公开
|
||||
## 运行时共享
|
||||
|
||||
本地运行时默认是**私有**的:只有运行时所有者可以在它上面创建智能体。工作区的 owner、admin 也不例外——运行时是别人的电脑,在上面跑智能体消耗的是对方的机器和工具凭据。
|
||||
|
||||
只有运行时所有者可以把它改为**公开**——管理员可以重命名或删除运行时,但要不要共享由所有者决定。公开后,工作区中的其他成员也可以选择这条运行时;这不会分享底层 AI 编程工具的登录凭据,只是允许成员把智能体的执行路由到这台电脑。
|
||||
|
||||
所有者把公开运行时重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者自己的智能体和运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。
|
||||
如需仅向部分成员开放,在运行时详情中选择**指定成员**,勾选一位或多位工作区成员,然后**保存**。所选成员可以选择该运行时并创建智能体。只有所有者能修改共享名单;获得共享权限不会同时获得重命名、删除或管理运行时的权限。
|
||||
|
||||
所有者移除共享成员、将公开范围改为指定成员,或重新设为**私有**时,Multica 会先展示受影响的智能体和活动运行,包括智能体构建会话。确认后会解绑其他成员的这些智能体,以 `runtime_access_revoked` 取消其排队中、已调度、执行中、等待本地目录和延迟中的运行,并暂停分配给这些智能体的已启用自动化。所有者和仍有权限成员的智能体与运行不受影响。影响范围变化时需要重新确认;遇到并发聊天写入时可能需要重试。恢复工作时,请先将智能体绑定到可用的运行时,再手动恢复已暂停的自动化。
|
||||
|
||||
取消会保留聊天历史,并在服务端保存草稿和附件恢复数据。网页端和桌面端沿用现有的草稿恢复入口;移动端暂未提供该入口。已开始但尚无输出的运行,会等待守护进程确认取消或服务端收尾宽限期结束,再决定是否恢复输入。已连接的守护进程通过常规检查接收取消。电脑离线时,服务端仍会立即撤销访问,但本地进程可能继续执行,直到重新连接并收到取消;服务端无法保证立即远程停止。
|
||||
|
||||
|
||||
@@ -346,6 +346,23 @@ export class TestApiClient {
|
||||
return (await res.json()) as { id: string };
|
||||
}
|
||||
|
||||
/** Seed an owned runtime without launching an actual agent tool. */
|
||||
async seedRuntime(name: string): Promise<string> {
|
||||
if (!this.workspaceId || !this.email) throw new Error("Select a workspace before seeding a runtime");
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`INSERT INTO agent_runtime (workspace_id, name, runtime_mode, provider, status, owner_id, last_seen_at)
|
||||
SELECT $1, $2, 'local', 'claude', 'online', id, now() FROM "user" WHERE email = $3 RETURNING id`,
|
||||
[this.workspaceId, name, this.email],
|
||||
);
|
||||
return result.rows[0].id as string;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds an existing user to this client's workspace and returns the member id. */
|
||||
async addMemberByEmail(email: string, role: "admin" | "member" = "member"): Promise<string> {
|
||||
if (!this.workspaceId) throw new Error("Cannot add a member before a workspace is selected");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
test("runtime owner can save specific members and revoke their access", async ({ page }, testInfo) => {
|
||||
const api = new TestApiClient();
|
||||
const member = new TestApiClient();
|
||||
const run = `${Date.now()}-${process.pid}`;
|
||||
await api.login(`runtime-owner-${run}@localhost`, "Runtime Owner");
|
||||
const workspace = await api.ensureWorkspace("Runtime sharing", `runtime-sharing-${run}`);
|
||||
await api.markUserOnboarded();
|
||||
try {
|
||||
await member.login(`runtime-member-${run}@localhost`, "Alice");
|
||||
const memberId = await api.addMemberByEmail(`runtime-member-${run}@localhost`);
|
||||
const runtimeId = await api.seedRuntime("Studio Mac");
|
||||
await page.addInitScript((token) => {
|
||||
localStorage.setItem("multica_token", token!);
|
||||
localStorage.setItem("multica:chat:isOpen", "false");
|
||||
}, api.getToken());
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/${workspace.slug}/runtimes/${runtimeId}`);
|
||||
await page.getByRole("row", { name: /Studio Mac Built-in/ }).click();
|
||||
const scope = (name: string) => page.getByRole("button", { name, exact: true });
|
||||
const patched = () => page.waitForResponse((response) => response.url().endsWith(`/api/runtimes/${runtimeId}`) && response.request().method() === "PATCH");
|
||||
await expect(scope("Private")).toHaveAttribute("aria-pressed", "true");
|
||||
await page.screenshot({ path: testInfo.outputPath("runtime-private.png"), fullPage: true });
|
||||
|
||||
// Choosing members opens a draft picker; nothing persists until Save.
|
||||
await scope("Members").click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
await page.getByRole("checkbox", { name: "Alice" }).check();
|
||||
await page.screenshot({ path: testInfo.outputPath("runtime-member-picker.png"), fullPage: true });
|
||||
const saved = patched();
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
expect((await saved).status()).toBe(200);
|
||||
await page.reload();
|
||||
await expect(scope("Members")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByRole("button", { name: /Alice.*Edit/ })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("runtime-shared.png"), fullPage: true });
|
||||
|
||||
// Private applies in one click.
|
||||
const revoked = patched();
|
||||
await scope("Private").click();
|
||||
expect((await revoked).status()).toBe(200);
|
||||
await page.reload();
|
||||
await expect(scope("Private")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await scope("Members").click();
|
||||
await page.getByRole("checkbox", { name: "Alice" }).check();
|
||||
const sharedAgain = patched();
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
expect((await sharedAgain).status()).toBe(200);
|
||||
await api.removeMember(memberId);
|
||||
await expect(scope("Private")).toHaveAttribute("aria-pressed", "true", { timeout: 15000 });
|
||||
await api.addMemberByEmail(`runtime-member-${run}@localhost`);
|
||||
await page.reload();
|
||||
await expect(scope("Private")).toHaveAttribute("aria-pressed", "true");
|
||||
} finally {
|
||||
await api.deleteWorkspace();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { AgentRuntimeSchema, EMPTY_RUNTIME } from "../runtimes/schema";
|
||||
import type { IssueWakeup, IssueWakeupInput, IssueWakeupSummaryRow, PausedWakeup, SystemWakeup, WakeupRun, WorkspaceSystemWakeup } from "../types/issue-wakeup";
|
||||
import type { WorkspaceWakeupPage, WorkspaceWakeupFilters } from "../types/issue-wakeup";
|
||||
import { WorkspaceWakeupPageSchema, IssueWakeupSchema, IssueWakeupSummaryRowSchema, PausedWakeupSchema, SystemWakeupSchema, WakeupRunSchema, WorkspaceSystemWakeupSchema } from "./schemas";
|
||||
@@ -2055,9 +2056,10 @@ export class ApiClient {
|
||||
// workspace_id alone is not enough: the server resolves the workspace from
|
||||
// the slug header first, so a caller listing another workspace's runtimes
|
||||
// must override the header too.
|
||||
return this.fetch(`/api/runtimes?${search}`, {
|
||||
const raw = await this.fetch<unknown>(`/api/runtimes?${search}`, {
|
||||
headers: workspaceHeader(workspaceSlug),
|
||||
});
|
||||
return parseWithFallback(raw, AgentRuntimeSchema.array(), [], { endpoint: "listRuntimes" });
|
||||
}
|
||||
|
||||
async listCloudRuntimeNodes(
|
||||
@@ -2404,6 +2406,7 @@ export class ApiClient {
|
||||
runtimeId: string,
|
||||
patch: {
|
||||
visibility?: "private" | "public";
|
||||
shared_with?: string[];
|
||||
/**
|
||||
* Custom display name. Pass an empty string to clear it (the server
|
||||
* reverts to the default name). Omit to leave it unchanged — a JSON
|
||||
@@ -2414,13 +2417,14 @@ export class ApiClient {
|
||||
apply_to_machine?: boolean;
|
||||
},
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
const raw = await this.fetch<unknown>(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return parseWithFallback(raw, AgentRuntimeSchema, EMPTY_RUNTIME, { endpoint: "updateRuntime" });
|
||||
}
|
||||
|
||||
// Confirmed counterpart to a public→private visibility PATCH. The PATCH
|
||||
// Confirmed counterpart to a runtime access restriction PATCH. The PATCH
|
||||
// returns a structured 409 when teammates still have bound agents or active
|
||||
// tasks on the runtime; callers present that plan, then submit its exact
|
||||
// agent/task ID sets here. The server rechecks both sets in one transaction.
|
||||
@@ -2428,12 +2432,14 @@ export class ApiClient {
|
||||
runtimeId: string,
|
||||
expectedNonownerAgentIds: string[],
|
||||
expectedTaskIds: string[],
|
||||
sharedWith?: string[],
|
||||
): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}/revoke-workspace-access`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_nonowner_agent_ids: expectedNonownerAgentIds,
|
||||
expected_task_ids: expectedTaskIds,
|
||||
shared_with: sharedWith,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRuntime } from "../types";
|
||||
import { isRuntimeUsableForUser } from "./access";
|
||||
@@ -40,6 +42,14 @@ describe("isRuntimeUsableForUser", () => {
|
||||
expect(isRuntimeUsableForUser(makeRuntime(), OTHER)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only selected members on a shared private runtime", () => {
|
||||
const runtime = makeRuntime({ shared_with: [OTHER] });
|
||||
expect(isRuntimeUsableForUser(runtime, OTHER)).toBe(true);
|
||||
expect(isRuntimeUsableForUser(runtime, OWNER)).toBe(true);
|
||||
expect(isRuntimeUsableForUser(runtime, "not-selected")).toBe(false);
|
||||
expect(isRuntimeUsableForUser({ ...runtime, owner_id: null }, OTHER)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets anyone use a public runtime", () => {
|
||||
expect(
|
||||
isRuntimeUsableForUser(makeRuntime({ visibility: "public" }), OTHER),
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { RuntimeDevice } from "../types";
|
||||
/**
|
||||
* Whether this member may run an agent on `runtime`.
|
||||
*
|
||||
* A private runtime is usable only by its owner; a public one by anyone in the
|
||||
* A private runtime is usable by its owner and explicitly selected members; a public one by anyone in the
|
||||
* workspace; an ownerless one by nobody. Workspace role does not enter into
|
||||
* it: a private runtime is someone's own machine, so not even an admin may
|
||||
* bind an agent to it, and only the owner may flip it to public. The server
|
||||
* bind an agent without a grant, and only the owner may change sharing. The server
|
||||
* enforces exactly this predicate (`canUseRuntimeForAgent`), so the picker and
|
||||
* the API/CLI never disagree (MUL-6126).
|
||||
*
|
||||
@@ -28,5 +28,6 @@ export function isRuntimeUsableForUser(
|
||||
if (!runtime.owner_id) return false;
|
||||
if (!currentUserId) return true;
|
||||
if (runtime.owner_id === currentUserId) return true;
|
||||
return runtime.visibility === "public";
|
||||
return runtime.visibility === "public" ||
|
||||
(runtime.visibility === "private" && (runtime.shared_with ?? []).includes(currentUserId));
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useUnbindAgentsAndDeleteRuntime(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Confirmed public→private transition. Like runtime deletion, this changes
|
||||
// Confirmed access restriction to private or selected members. Like runtime deletion, this changes
|
||||
// runtime rows, agent bindings, and task presence, so every affected server
|
||||
// projection is invalidated after the transaction settles.
|
||||
export function useRevokeRuntimeWorkspaceAccess(wsId: string) {
|
||||
@@ -52,15 +52,18 @@ export function useRevokeRuntimeWorkspaceAccess(wsId: string) {
|
||||
runtimeId,
|
||||
expectedNonownerAgentIds,
|
||||
expectedTaskIds,
|
||||
sharedWith,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
expectedNonownerAgentIds: string[];
|
||||
expectedTaskIds: string[];
|
||||
sharedWith?: string[];
|
||||
}) =>
|
||||
api.revokeRuntimeWorkspaceAccess(
|
||||
runtimeId,
|
||||
expectedNonownerAgentIds,
|
||||
expectedTaskIds,
|
||||
sharedWith,
|
||||
),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
@@ -83,6 +86,7 @@ export function useUpdateRuntime(wsId: string) {
|
||||
runtimeId: string;
|
||||
patch: {
|
||||
visibility?: "private" | "public";
|
||||
shared_with?: string[];
|
||||
// Empty string clears the custom name; omit to leave unchanged.
|
||||
custom_name?: string;
|
||||
apply_to_machine?: boolean;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWithFallback } from "../api/schema";
|
||||
import { AgentRuntimeSchema, EMPTY_RUNTIME } from "./schema";
|
||||
|
||||
describe("runtime sharing response contract", () => {
|
||||
it("preserves grants from a new server and defaults missing grants to empty", () => {
|
||||
const member = "11111111-1111-4111-8111-111111111111";
|
||||
expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w", shared_with: [member] }).shared_with).toEqual([member]);
|
||||
expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w" }).shared_with).toEqual([]);
|
||||
});
|
||||
it.each([null, "all", [123], ["not-a-uuid"]])("fails closed for malformed grants: %j", (shared_with) => {
|
||||
expect(AgentRuntimeSchema.parse({ id: "r", workspace_id: "w", shared_with }).shared_with).toEqual([]);
|
||||
});
|
||||
it("falls back safely for a malformed runtime", () => {
|
||||
expect(parseWithFallback({ id: 123 }, AgentRuntimeSchema, EMPTY_RUNTIME, { endpoint: "test" })).toEqual(EMPTY_RUNTIME);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentRuntime } from "../types";
|
||||
|
||||
export const AgentRuntimeSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
daemon_id: z.string().nullable().catch(null),
|
||||
name: z.string().catch(""),
|
||||
custom_name: z.string().nullable().catch(null),
|
||||
runtime_mode: z.enum(["local", "cloud"]).catch("local"),
|
||||
provider: z.string().catch(""),
|
||||
launch_header: z.string().catch(""),
|
||||
status: z.enum(["online", "offline"]).catch("offline"),
|
||||
device_info: z.string().catch(""),
|
||||
metadata: z.record(z.string(), z.unknown()).catch({}),
|
||||
owner_id: z.string().nullable().catch(null),
|
||||
visibility: z.enum(["private", "public"]).catch("private"),
|
||||
shared_with: z.array(z.string().uuid()).catch([]),
|
||||
profile_id: z.string().nullable().catch(null),
|
||||
last_seen_at: z.string().nullable().catch(null),
|
||||
created_at: z.string().catch(""),
|
||||
updated_at: z.string().catch(""),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_RUNTIME: AgentRuntime = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
daemon_id: null,
|
||||
name: "",
|
||||
runtime_mode: "local",
|
||||
provider: "",
|
||||
launch_header: "",
|
||||
status: "offline",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
shared_with: [],
|
||||
last_seen_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
@@ -48,8 +48,8 @@ export interface AgentInvocationTargetInput {
|
||||
|
||||
// Runtime visibility is a separate axis from agent visibility — different
|
||||
// vocabulary because it gates a different action. "private" (default) means
|
||||
// only the runtime owner can bind agents to it — workspace admins included,
|
||||
// and only the owner may flip the flag (MUL-6126); "public" opens binding to
|
||||
// the runtime owner and shared_with members can bind agents to it;
|
||||
// only the owner may edit access (MUL-6126); "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.
|
||||
@@ -76,6 +76,8 @@ export interface RuntimeDevice {
|
||||
owner_id: string | null;
|
||||
/** Defaults to "private" when the backend predates the visibility flag. */
|
||||
visibility: RuntimeVisibility;
|
||||
/** Workspace user IDs granted access while visibility is private. */
|
||||
shared_with?: string[];
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -174,28 +174,43 @@
|
||||
"diagnostics_visibility": "Visibility",
|
||||
"visibility_label": {
|
||||
"private": "Private",
|
||||
"public": "Public"
|
||||
"public": "Public",
|
||||
"members": "Specific members"
|
||||
},
|
||||
"visibility_choice": {
|
||||
"private": "Private",
|
||||
"public": "Public",
|
||||
"members": "Members"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "Only you can create agents on this runtime.",
|
||||
"public": "Any workspace member can create agents on this runtime."
|
||||
"public": "Any workspace member can create agents on this runtime.",
|
||||
"members": "Only you and the selected members can create agents on this runtime."
|
||||
},
|
||||
"visibility_hint_readonly": {
|
||||
"private": "Only the runtime owner can create agents on this runtime, and only they can switch it to Public.",
|
||||
"public": "Any workspace member can create agents on this runtime. Only the runtime owner can switch it back to Private."
|
||||
"public": "Any workspace member can create agents on this runtime. Only the runtime owner can switch it back to Private.",
|
||||
"members": "Only the owner and selected members can create agents on this runtime."
|
||||
},
|
||||
"visibility_members_title": "Share with members",
|
||||
"visibility_members_search": "Search members",
|
||||
"visibility_members_none": "No other workspace members yet.",
|
||||
"visibility_members_no_match": "No members match.",
|
||||
"visibility_members_edit": "Edit",
|
||||
"visibility_members_count_one": "{{count}} member",
|
||||
"visibility_members_count_other": "{{count}} members",
|
||||
"visibility_toast_updated": "Visibility set to {{visibility}}",
|
||||
"visibility_toast_failed": "Failed to update visibility",
|
||||
"visibility_revoke_dialog": {
|
||||
"title": "Make this Runtime private?",
|
||||
"description": "Making \"{{name}}\" private will unbind {{count}} teammate agents, cancel {{taskCount}} active runs on this machine, and pause active autopilots assigned to those agents. Your own agents and runs are unaffected.",
|
||||
"title": "Restrict runtime access?",
|
||||
"description": "Restricting access to \"{{name}}\" will unbind {{count}} affected agents, cancel {{taskCount}} active runs, and pause active autopilots assigned to those agents. Agents and runs that keep access are unaffected.",
|
||||
"warning": "Access is revoked immediately on the server. Connected daemons stop through their normal cancellation check. An offline machine cannot receive the stop request until it reconnects; its local process may continue meanwhile.",
|
||||
"plan_changed": "The access plan changed while this dialog was open. Review it and confirm again.",
|
||||
"checkbox": "I understand that the listed teammate agents will be unbound, their active runs cancelled, and their active autopilots paused.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Make Runtime private",
|
||||
"confirm": "Restrict access",
|
||||
"submitting": "Revoking access...",
|
||||
"toast_success": "Runtime is now private and teammate access was revoked",
|
||||
"toast_success": "Runtime access updated",
|
||||
"toast_failed": "Failed to revoke runtime access"
|
||||
},
|
||||
"delete_dialog": {
|
||||
|
||||
@@ -174,28 +174,43 @@
|
||||
"diagnostics_visibility": "Visibilité",
|
||||
"visibility_label": {
|
||||
"private": "Privé",
|
||||
"public": "Public"
|
||||
"public": "Public",
|
||||
"members": "Membres spécifiques"
|
||||
},
|
||||
"visibility_choice": {
|
||||
"private": "Privé",
|
||||
"public": "Public",
|
||||
"members": "Membres"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "Vous seul pouvez créer des agents sur ce runtime.",
|
||||
"public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime."
|
||||
"public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime.",
|
||||
"members": "Seuls vous et les membres sélectionnés pouvez créer des agents sur cet environnement."
|
||||
},
|
||||
"visibility_hint_readonly": {
|
||||
"private": "Seul le propriétaire du runtime peut y créer des agents, et lui seul peut le passer en Public.",
|
||||
"public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime. Seul son propriétaire peut le repasser en Privé."
|
||||
"public": "Tout membre de l'espace de travail peut créer des agents sur ce runtime. Seul son propriétaire peut le repasser en Privé.",
|
||||
"members": "Seuls le propriétaire et les membres sélectionnés peuvent créer des agents sur cet environnement."
|
||||
},
|
||||
"visibility_members_title": "Partager avec des membres",
|
||||
"visibility_members_search": "Rechercher des membres",
|
||||
"visibility_members_none": "Aucun autre membre dans l’espace de travail.",
|
||||
"visibility_members_no_match": "Aucun membre ne correspond.",
|
||||
"visibility_members_edit": "Modifier",
|
||||
"visibility_members_count_one": "{{count}} membre",
|
||||
"visibility_members_count_other": "{{count}} membres",
|
||||
"visibility_toast_updated": "Visibilité définie sur {{visibility}}",
|
||||
"visibility_toast_failed": "Échec de la mise à jour de la visibilité",
|
||||
"visibility_revoke_dialog": {
|
||||
"title": "Rendre ce runtime privé ?",
|
||||
"description": "Rendre « {{name}} » privé dissociera {{count}} agents d'autres membres, annulera {{taskCount}} exécutions actives sur cette machine et mettra en pause les automatisations actives assignées à ces agents. Vos propres agents et exécutions ne seront pas affectés.",
|
||||
"title": "Restreindre l’accès à cet environnement ?",
|
||||
"description": "Restreindre l’accès à « {{name}} » détachera {{count}} agents concernés, annulera {{taskCount}} exécutions actives et suspendra leurs autopilotes actifs. Les agents et exécutions qui conservent l’accès ne sont pas affectés.",
|
||||
"warning": "L'accès est révoqué immédiatement sur le serveur. Les daemons connectés s'arrêtent lors de leur vérification habituelle des annulations. Une machine hors ligne ne recevra la demande d'arrêt qu'à sa reconnexion ; son processus local peut continuer entre-temps.",
|
||||
"plan_changed": "Le plan d'accès a changé pendant que cette boîte de dialogue était ouverte. Vérifiez-le et confirmez à nouveau.",
|
||||
"checkbox": "Je comprends que les agents des autres membres listés seront dissociés, leurs exécutions actives annulées et leurs automatisations actives mises en pause.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Rendre le runtime privé",
|
||||
"confirm": "Restreindre l’accès",
|
||||
"submitting": "Révocation de l'accès...",
|
||||
"toast_success": "Le runtime est désormais privé et l'accès des autres membres a été révoqué",
|
||||
"toast_success": "Accès à l’environnement mis à jour",
|
||||
"toast_failed": "Échec de la révocation de l'accès au runtime"
|
||||
},
|
||||
"delete_dialog": {
|
||||
|
||||
@@ -169,28 +169,42 @@
|
||||
"diagnostics_visibility": "公開範囲",
|
||||
"visibility_label": {
|
||||
"private": "非公開",
|
||||
"public": "公開"
|
||||
"public": "公開",
|
||||
"members": "指定メンバー"
|
||||
},
|
||||
"visibility_choice": {
|
||||
"private": "非公開",
|
||||
"public": "公開",
|
||||
"members": "メンバー"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "あなたのみが、このランタイムでエージェントを作成できます。",
|
||||
"public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。"
|
||||
"public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。",
|
||||
"members": "あなたと選択したメンバーだけがこのランタイムでエージェントを作成できます。"
|
||||
},
|
||||
"visibility_hint_readonly": {
|
||||
"private": "このランタイムでエージェントを作成できるのは所有者だけで、公開に切り替えられるのも所有者だけです。",
|
||||
"public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。非公開に戻せるのは所有者だけです。"
|
||||
"public": "ワークスペースのメンバーなら誰でも、このランタイムでエージェントを作成できます。非公開に戻せるのは所有者だけです。",
|
||||
"members": "所有者と選択したメンバーのみ、このランタイムでエージェントを作成できます。"
|
||||
},
|
||||
"visibility_members_title": "メンバーと共有",
|
||||
"visibility_members_search": "メンバーを検索",
|
||||
"visibility_members_none": "ワークスペースに他のメンバーはいません。",
|
||||
"visibility_members_no_match": "一致するメンバーはいません。",
|
||||
"visibility_members_edit": "編集",
|
||||
"visibility_members_count_other": "{{count}} 人のメンバー",
|
||||
"visibility_toast_updated": "公開範囲を {{visibility}} に設定しました",
|
||||
"visibility_toast_failed": "公開範囲を更新できませんでした",
|
||||
"visibility_revoke_dialog": {
|
||||
"title": "この Runtime を private にしますか?",
|
||||
"description": "\"{{name}}\" を private にすると、他のメンバーのエージェント {{count}} 件の接続を解除し、このマシン上のアクティブな実行 {{taskCount}} 件をキャンセルします。これらのエージェントに割り当てられた有効な自動化も一時停止します。自分のエージェントと実行には影響しません。",
|
||||
"title": "ランタイムへのアクセスを制限しますか?",
|
||||
"description": "「{{name}}」へのアクセスを制限すると、対象のエージェント {{count}} 件の紐付けを解除し、実行中の {{taskCount}} 件をキャンセルして、対象エージェントの有効なオートパイロットを一時停止します。アクセスを維持するエージェントと実行には影響しません。",
|
||||
"warning": "サーバー上のアクセスは直ちに取り消されます。接続中のデーモンは通常のキャンセル確認で停止します。オフラインのマシンは再接続するまで停止要求を受信できず、その間ローカルプロセスが続く場合があります。",
|
||||
"plan_changed": "このダイアログを開いている間にアクセス計画が変わりました。内容を確認して、もう一度確定してください。",
|
||||
"checkbox": "一覧の他メンバーのエージェントの接続が解除され、アクティブな実行がキャンセルされ、有効な自動化が一時停止することを理解しました。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "Runtime を private にする",
|
||||
"confirm": "アクセスを制限",
|
||||
"submitting": "アクセスを取り消しています...",
|
||||
"toast_success": "Runtime を private にし、他メンバーのアクセスを取り消しました",
|
||||
"toast_success": "ランタイムのアクセスを更新しました",
|
||||
"toast_failed": "Runtime アクセスを取り消せませんでした"
|
||||
},
|
||||
"delete_dialog": {
|
||||
|
||||
@@ -169,28 +169,42 @@
|
||||
"diagnostics_visibility": "공개 범위",
|
||||
"visibility_label": {
|
||||
"private": "비공개",
|
||||
"public": "공개"
|
||||
"public": "공개",
|
||||
"members": "지정 멤버"
|
||||
},
|
||||
"visibility_choice": {
|
||||
"private": "비공개",
|
||||
"public": "공개",
|
||||
"members": "멤버"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "본인만 이 런타임에 에이전트를 만들 수 있습니다.",
|
||||
"public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다."
|
||||
"public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다.",
|
||||
"members": "나와 선택한 멤버만 이 런타임에서 에이전트를 만들 수 있습니다."
|
||||
},
|
||||
"visibility_hint_readonly": {
|
||||
"private": "이 런타임에 에이전트를 만들 수 있는 사람은 런타임 owner뿐이며, 공개로 바꿀 수 있는 사람도 owner뿐입니다.",
|
||||
"public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다. 다시 비공개로 바꿀 수 있는 사람은 런타임 owner뿐입니다."
|
||||
"public": "워크스페이스 멤버라면 누구나 이 런타임에 에이전트를 만들 수 있습니다. 다시 비공개로 바꿀 수 있는 사람은 런타임 owner뿐입니다.",
|
||||
"members": "소유자와 선택한 멤버만 이 런타임에서 에이전트를 만들 수 있습니다."
|
||||
},
|
||||
"visibility_members_title": "멤버와 공유",
|
||||
"visibility_members_search": "멤버 검색",
|
||||
"visibility_members_none": "워크스페이스에 다른 멤버가 없습니다.",
|
||||
"visibility_members_no_match": "일치하는 멤버가 없습니다.",
|
||||
"visibility_members_edit": "편집",
|
||||
"visibility_members_count_other": "멤버 {{count}}명",
|
||||
"visibility_toast_updated": "공개 범위를 {{visibility}}(으)로 설정했습니다",
|
||||
"visibility_toast_failed": "공개 범위를 업데이트하지 못했습니다",
|
||||
"visibility_revoke_dialog": {
|
||||
"title": "이 Runtime을 private으로 전환할까요?",
|
||||
"description": "\"{{name}}\"을 private으로 전환하면 다른 멤버의 에이전트 {{count}}개 연결이 해제되고 이 컴퓨터의 활성 실행 {{taskCount}}개가 취소됩니다. 해당 에이전트에 할당된 활성 자동화도 일시 중지됩니다. 본인의 에이전트와 실행에는 영향을 주지 않습니다.",
|
||||
"title": "런타임 접근을 제한할까요?",
|
||||
"description": "\"{{name}}\" 접근을 제한하면 영향받는 에이전트 {{count}}개의 연결을 해제하고 실행 {{taskCount}}개를 취소하며 해당 에이전트의 활성 오토파일럿을 일시 중지합니다. 접근 권한이 유지되는 에이전트와 실행은 영향받지 않습니다.",
|
||||
"warning": "서버의 접근 권한은 즉시 취소됩니다. 연결된 데몬은 일반 취소 확인 과정에서 중단합니다. 오프라인 컴퓨터는 다시 연결될 때까지 중단 요청을 받을 수 없으므로 그동안 로컬 프로세스가 계속 실행될 수 있습니다.",
|
||||
"plan_changed": "이 대화 상자가 열린 동안 접근 계획이 변경되었습니다. 내용을 검토하고 다시 확인하세요.",
|
||||
"checkbox": "목록의 다른 멤버 에이전트 연결이 해제되고 활성 실행이 취소되며 활성 자동화가 일시 중지됨을 이해했습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "Runtime을 private으로 전환",
|
||||
"confirm": "접근 제한",
|
||||
"submitting": "접근을 취소하는 중...",
|
||||
"toast_success": "Runtime이 private으로 전환되었고 다른 멤버의 접근이 취소되었습니다",
|
||||
"toast_success": "런타임 접근 권한을 변경했습니다",
|
||||
"toast_failed": "Runtime 접근을 취소하지 못했습니다"
|
||||
},
|
||||
"delete_dialog": {
|
||||
|
||||
@@ -169,28 +169,42 @@
|
||||
"diagnostics_visibility": "可见性",
|
||||
"visibility_label": {
|
||||
"private": "私有",
|
||||
"public": "公开"
|
||||
"public": "公开",
|
||||
"members": "指定成员"
|
||||
},
|
||||
"visibility_choice": {
|
||||
"private": "私有",
|
||||
"public": "公开",
|
||||
"members": "指定成员"
|
||||
},
|
||||
"visibility_hint": {
|
||||
"private": "仅你可以在此运行时上创建智能体。",
|
||||
"public": "工作区内任何成员都可以在此运行时上创建智能体。"
|
||||
"public": "工作区内任何成员都可以在此运行时上创建智能体。",
|
||||
"members": "仅你和所选成员可以在此运行时上创建智能体。"
|
||||
},
|
||||
"visibility_hint_readonly": {
|
||||
"private": "仅运行时所有者可以在此运行时上创建智能体,也只有他们能切换为「公开」。",
|
||||
"public": "工作区内任何成员都可以在此运行时上创建智能体。只有运行时所有者能切回「私有」。"
|
||||
"public": "工作区内任何成员都可以在此运行时上创建智能体。只有运行时所有者能切回「私有」。",
|
||||
"members": "仅所有者和所选成员可以在此运行时创建智能体。"
|
||||
},
|
||||
"visibility_members_title": "共享给成员",
|
||||
"visibility_members_search": "搜索成员",
|
||||
"visibility_members_none": "工作区暂无其他成员。",
|
||||
"visibility_members_no_match": "没有匹配的成员。",
|
||||
"visibility_members_edit": "编辑",
|
||||
"visibility_members_count_other": "{{count}} 位成员",
|
||||
"visibility_toast_updated": "可见性已设为\"{{visibility}}\"",
|
||||
"visibility_toast_failed": "更新可见性失败",
|
||||
"visibility_revoke_dialog": {
|
||||
"title": "将此运行时设为私有?",
|
||||
"description": "将 \"{{name}}\" 设为私有后,会解绑 {{count}} 个其他成员的智能体、取消此机器上的 {{taskCount}} 次活动运行,并暂停分配给这些智能体的已启用自动化。你自己的智能体和运行不受影响。",
|
||||
"title": "限制运行时访问权限?",
|
||||
"description": "限制 \"{{name}}\" 的访问权限将解绑 {{count}} 个受影响的智能体、取消 {{taskCount}} 次运行,并暂停分配给这些智能体的自动化。仍有权限的智能体和运行不受影响。",
|
||||
"warning": "服务端会立即撤销访问。已连接的守护进程会在常规取消检查时停止运行。离线机器需重新连接才能收到停止请求,本地进程在此期间可能继续执行。",
|
||||
"plan_changed": "弹窗打开期间,访问方案发生了变化。请重新确认。",
|
||||
"checkbox": "我已了解:列表中的其他成员智能体会被解绑,其活动运行会被取消,已启用的自动化会被暂停。",
|
||||
"cancel": "取消",
|
||||
"confirm": "设为私有",
|
||||
"confirm": "限制访问",
|
||||
"submitting": "正在撤销访问...",
|
||||
"toast_success": "运行时已设为私有,其他成员的访问已撤销",
|
||||
"toast_success": "已更新运行时访问权限",
|
||||
"toast_failed": "撤销运行时访问失败"
|
||||
},
|
||||
"delete_dialog": {
|
||||
|
||||
@@ -46,21 +46,21 @@ describe("runtime access revocation confirmation", () => {
|
||||
};
|
||||
render(<I18nProvider locale="en" resources={{ en: { runtimes: enRuntimes } }}>
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
|
||||
<RevokeRuntimeAccessDialog open onOpenChange={close} runtime={runtime} wsId="ws-1" initialPlan={{ agents: body.nonowner_agents, activeTaskIds: [taskId] }} />
|
||||
<RevokeRuntimeAccessDialog open onOpenChange={close} runtime={runtime} wsId="ws-1" initialPlan={{ agents: body.nonowner_agents, activeTaskIds: [taskId] }} sharedWith={["remaining-member"]} />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>);
|
||||
expect(screen.getByRole("alertdialog", { name: "Make this Runtime private?" })).toHaveAccessibleDescription(/pause active autopilots/);
|
||||
const confirm = screen.getByRole("button", { name: "Make Runtime private" });
|
||||
expect(screen.getByRole("alertdialog", { name: "Restrict runtime access?" })).toHaveAccessibleDescription(/pause active autopilots/);
|
||||
const confirm = screen.getByRole("button", { name: "Restrict access" });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(confirm);
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Review it and confirm again");
|
||||
expect(confirm).toBeDisabled();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(revoke).toHaveBeenNthCalledWith(1, runtime.id, [agentId], [taskId]);
|
||||
expect(revoke).toHaveBeenNthCalledWith(1, runtime.id, [agentId], [taskId], ["remaining-member"]);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() => expect(close).toHaveBeenCalledWith(false));
|
||||
expect(revoke).toHaveBeenNthCalledWith(2, runtime.id, [agentId], [taskId, nextTaskId]);
|
||||
expect(revoke).toHaveBeenNthCalledWith(2, runtime.id, [agentId], [taskId, nextTaskId], ["remaining-member"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,12 +44,14 @@ export function RevokeRuntimeAccessDialog({
|
||||
runtime,
|
||||
wsId,
|
||||
initialPlan,
|
||||
sharedWith,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
runtime: AgentRuntime;
|
||||
wsId: string;
|
||||
initialPlan: RuntimeAccessRevocationPlan | null;
|
||||
sharedWith?: string[];
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
const revokeAccess = useRevokeRuntimeWorkspaceAccess(wsId);
|
||||
@@ -72,6 +74,7 @@ export function RevokeRuntimeAccessDialog({
|
||||
runtimeId: runtime.id,
|
||||
expectedNonownerAgentIds: plan.agents.map((agent) => agent.id),
|
||||
expectedTaskIds: plan.activeTaskIds,
|
||||
sharedWith,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ComponentProps } from "react";
|
||||
import { Globe, Lock, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useUpdateRuntime } from "@multica/core/runtimes/mutations";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
RevokeRuntimeAccessDialog,
|
||||
parseRuntimeAccessRevocationPlan,
|
||||
type RuntimeAccessRevocationPlan,
|
||||
} from "./revoke-runtime-access-dialog";
|
||||
|
||||
type RuntimeAccessScope = "private" | "public" | "members";
|
||||
|
||||
// Below this size the member list fits without scrolling, so a search field
|
||||
// would only add noise.
|
||||
const MEMBER_SEARCH_THRESHOLD = 7;
|
||||
|
||||
const SCOPE_ICON = { private: Lock, public: Globe, members: Users } as const;
|
||||
|
||||
export function runtimeAccessScope(runtime: AgentRuntime): RuntimeAccessScope {
|
||||
if (runtime.visibility === "public") return "public";
|
||||
return (runtime.shared_with?.length ?? 0) > 0 ? "members" : "private";
|
||||
}
|
||||
|
||||
export function RuntimeAccessReadout({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const scope = runtimeAccessScope(runtime);
|
||||
const Icon = SCOPE_ICON[scope];
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2 py-1.5 text-caption">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">{t(($) => $.detail.visibility_label[scope])}</span>
|
||||
</span>
|
||||
} />
|
||||
<TooltipContent>{t(($) => $.detail.visibility_hint_readonly[scope])}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-only access control. Private and Public apply in one click, as before
|
||||
* member sharing existed; restricting access still goes through the reviewed
|
||||
* revocation dialog when the server reports affected agents or runs. Choosing
|
||||
* members opens a draft popover that persists only on Save, so a partial
|
||||
* selection is never shared.
|
||||
*/
|
||||
export function RuntimeAccessEditor({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const updateRuntime = useUpdateRuntime(wsId);
|
||||
const current = runtimeAccessScope(runtime);
|
||||
const persisted = useMemo(() => runtime.shared_with ?? [], [runtime.shared_with]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [revokePlan, setRevokePlan] = useState<RuntimeAccessRevocationPlan | null>(null);
|
||||
const [revokeOpen, setRevokeOpen] = useState(false);
|
||||
const [revokeMembers, setRevokeMembers] = useState<string[]>([]);
|
||||
const disabled = updateRuntime.isPending || revokeOpen;
|
||||
|
||||
const recipients = useMemo(
|
||||
() => members.filter((member) => persisted.includes(member.user_id)),
|
||||
[members, persisted],
|
||||
);
|
||||
|
||||
const apply = (scope: RuntimeAccessScope, sharedWith: string[], onApplied?: () => void) => {
|
||||
updateRuntime.mutate(
|
||||
{
|
||||
runtimeId: runtime.id,
|
||||
patch: { visibility: scope === "public" ? "public" : "private", shared_with: sharedWith },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onApplied?.();
|
||||
toast.success(t(($) => $.detail.visibility_toast_updated, {
|
||||
visibility: t(($) => $.detail.visibility_label[scope]),
|
||||
}));
|
||||
},
|
||||
onError: (err) => {
|
||||
const plan = parseRuntimeAccessRevocationPlan(err);
|
||||
if (plan) {
|
||||
onApplied?.();
|
||||
setRevokeMembers(sharedWith);
|
||||
setRevokePlan(plan);
|
||||
setRevokeOpen(true);
|
||||
return;
|
||||
}
|
||||
toast.error(err instanceof Error && err.message ? err.message : t(($) => $.detail.visibility_toast_failed));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(($) => $.detail.diagnostics_visibility)}
|
||||
className="flex w-full items-center gap-0.5 rounded-md bg-muted p-0.5"
|
||||
>
|
||||
{(["private", "public"] as const).map((scope) => (
|
||||
<ScopeButton
|
||||
key={scope}
|
||||
scope={scope}
|
||||
active={current === scope}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (current !== scope) apply(scope, []);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Popover open={pickerOpen} onOpenChange={(open) => setPickerOpen(open && !disabled)}>
|
||||
<PopoverTrigger
|
||||
render={<ScopeButton scope="members" active={current === "members"} disabled={disabled} />}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
{pickerOpen ? (
|
||||
<MemberPicker
|
||||
members={members.filter((member) => member.user_id !== runtime.owner_id)}
|
||||
initial={persisted}
|
||||
saving={updateRuntime.isPending}
|
||||
onCancel={() => setPickerOpen(false)}
|
||||
onSave={(ids) => apply("members", ids, () => setPickerOpen(false))}
|
||||
/>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{current === "members" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-1 py-1 text-left text-caption text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none"
|
||||
>
|
||||
<span className="flex shrink-0 -space-x-1.5">
|
||||
{recipients.slice(0, 4).map((member) => (
|
||||
<ActorAvatar key={member.user_id} actorType="member" actorId={member.user_id} size="xs" />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{recipients.length > 0
|
||||
? recipients.map((member) => member.name).join(", ")
|
||||
: t(($) => $.detail.visibility_members_count, { count: persisted.length })}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 font-medium text-foreground">
|
||||
{t(($) => $.detail.visibility_members_edit)}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<RevokeRuntimeAccessDialog open={revokeOpen} onOpenChange={setRevokeOpen}
|
||||
runtime={runtime} wsId={wsId} initialPlan={revokePlan} sharedWith={revokeMembers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeButton({
|
||||
scope,
|
||||
active,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
scope: RuntimeAccessScope;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
} & ComponentProps<"button">) {
|
||||
const { t } = useT("runtimes");
|
||||
const Icon = SCOPE_ICON[scope];
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
className={`inline-flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-xs px-2 py-1 text-caption font-medium transition-colors ${
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{t(($) => $.detail.visibility_choice[scope])}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{t(($) => $.detail.visibility_hint[scope])}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberPicker({
|
||||
members,
|
||||
initial,
|
||||
saving,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
members: Array<{ user_id: string; name: string }>;
|
||||
initial: string[];
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (ids: string[]) => void;
|
||||
}) {
|
||||
const { t } = useT("runtimes");
|
||||
const { t: tc } = useT("common");
|
||||
const [selected, setSelected] = useState(initial);
|
||||
const [query, setQuery] = useState("");
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = needle
|
||||
? members.filter((member) => member.name.toLowerCase().includes(needle))
|
||||
: members;
|
||||
// Order-insensitive: the server stores the list sorted.
|
||||
const dirty = selected.length !== initial.length || selected.some((id) => !initial.includes(id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="space-y-2 border-b px-3 py-2.5">
|
||||
<p className="text-caption font-medium">{t(($) => $.detail.visibility_members_title)}</p>
|
||||
{members.length >= MEMBER_SEARCH_THRESHOLD ? (
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t(($) => $.detail.visibility_members_search)}
|
||||
aria-label={t(($) => $.detail.visibility_members_search)}
|
||||
className="h-8"
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto overscroll-contain py-1">
|
||||
{members.length === 0 || visible.length === 0 ? (
|
||||
<p className="px-3 py-3 text-caption text-muted-foreground">
|
||||
{members.length === 0
|
||||
? t(($) => $.detail.visibility_members_none)
|
||||
: t(($) => $.detail.visibility_members_no_match)}
|
||||
</p>
|
||||
) : visible.map((member) => {
|
||||
const id = `runtime-share-${member.user_id}`;
|
||||
return (
|
||||
<div key={member.user_id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-surface-hover">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={selected.includes(member.user_id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) => setSelected((ids) => checked === true
|
||||
? [...ids, member.user_id]
|
||||
: ids.filter((value) => value !== member.user_id))}
|
||||
/>
|
||||
<label htmlFor={id} className="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
|
||||
<ActorAvatar actorType="member" actorId={member.user_id} size="sm" />
|
||||
<span className="truncate text-caption">{member.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t px-3 py-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={saving}>
|
||||
{tc(($) => $.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => onSave(selected)}
|
||||
disabled={saving || !dirty || selected.length === 0}
|
||||
aria-busy={saving}
|
||||
>
|
||||
{tc(($) => $.save)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,9 +226,10 @@ describe("RuntimeDetail visibility section", () => {
|
||||
|
||||
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();
|
||||
expect(screen.getByRole("group", { name: "Visibility" })).toBeInTheDocument();
|
||||
for (const name of ["Private", "Public", "Members"]) {
|
||||
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a reviewed revocation plan instead of silently making an in-use public runtime private", async () => {
|
||||
@@ -239,11 +240,11 @@ describe("RuntimeDetail visibility section", () => {
|
||||
});
|
||||
renderDetail(makeRuntime({ visibility: "public" }));
|
||||
|
||||
fireEvent.click(screen.getByText("Private"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Private" }));
|
||||
|
||||
expect(await screen.findByText("Make this Runtime private?")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Restrict runtime access?")).toBeInTheDocument();
|
||||
expect(screen.getByText("Teammate Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your own agents and runs are unaffected/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agents and runs that keep access are unaffected/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pause active autopilots assigned to those agents/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -292,14 +293,90 @@ describe("RuntimeDetail visibility section", () => {
|
||||
expect(screen.getAllByText("Research Pi")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("flips visibility to public when the owner clicks the Public choice", async () => {
|
||||
it("applies Public in one click, without a separate Save step", async () => {
|
||||
renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" }));
|
||||
fireEvent.click(screen.getByText("Public"));
|
||||
expect(screen.getByRole("button", { name: "Private" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Public" }));
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { visibility: "public" }),
|
||||
expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", { visibility: "public", shared_with: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send a request when the active scope is clicked again", () => {
|
||||
renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Private" }));
|
||||
expect(mockUpdateRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the selected members only after explicit confirmation", async () => {
|
||||
mockQueryData.members = [
|
||||
{ user_id: "user-me", role: "member", name: "Me" },
|
||||
{ user_id: "user-alice", role: "member", name: "Alice" },
|
||||
{ user_id: "user-bob", role: "member", name: "Bob" },
|
||||
];
|
||||
renderDetail(makeRuntime({}));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Members" }));
|
||||
expect(await screen.findByText("Share with members")).toBeInTheDocument();
|
||||
// The owner is never offered as a recipient.
|
||||
expect(screen.queryByRole("checkbox", { name: "Me" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||
expect(mockUpdateRuntime).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(mockUpdateRuntime).toHaveBeenCalledWith("rt-1", {
|
||||
visibility: "private", shared_with: ["user-alice"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("discards the member draft on Cancel", async () => {
|
||||
mockQueryData.members = [
|
||||
{ user_id: "user-me", role: "member", name: "Me" },
|
||||
{ user_id: "user-alice", role: "member", name: "Alice" },
|
||||
];
|
||||
renderDetail(makeRuntime({}));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Members" }));
|
||||
fireEvent.click(await screen.findByRole("checkbox", { name: "Alice" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByText("Share with members")).not.toBeInTheDocument());
|
||||
expect(mockUpdateRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("summarizes current recipients and reopens the picker with them selected", async () => {
|
||||
mockQueryData.members = [
|
||||
{ user_id: "user-me", role: "member", name: "Me" },
|
||||
{ user_id: "user-alice", role: "member", name: "Alice" },
|
||||
{ user_id: "user-bob", role: "member", name: "Bob" },
|
||||
];
|
||||
renderDetail(makeRuntime({ shared_with: ["user-alice"] }));
|
||||
expect(screen.getByRole("button", { name: "Members" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Alice.*Edit/ }));
|
||||
expect(await screen.findByRole("checkbox", { name: "Alice" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Bob" })).not.toBeChecked();
|
||||
// An unchanged selection is not saveable.
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("filters a long member list by name", async () => {
|
||||
mockQueryData.members = [
|
||||
{ user_id: "user-me", role: "member", name: "Me" },
|
||||
...["Alice", "Bob", "Carol", "Dave", "Erin", "Frank", "Grace"].map((name) => ({
|
||||
user_id: `user-${name.toLowerCase()}`, role: "member", name,
|
||||
})),
|
||||
];
|
||||
renderDetail(makeRuntime({}));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Members" }));
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Search members" }), { target: { value: "gr" } });
|
||||
expect(screen.getByRole("checkbox", { name: "Grace" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: "Alice" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a shared recipient the read-only scope and runtime usage", () => {
|
||||
renderDetail(makeRuntime({ owner_id: "someone-else", shared_with: ["user-me"] }));
|
||||
expect(screen.getByText("Specific members")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("usage-section")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Globe,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,7 +18,6 @@ import type {
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useUpdateRuntime } from "@multica/core/runtimes/mutations";
|
||||
import {
|
||||
deriveRuntimeHealth,
|
||||
isRuntimeUsableForUser,
|
||||
@@ -45,11 +43,7 @@ import { HealthBadge } from "./shared";
|
||||
import { ProviderLogo } from "./provider-logo";
|
||||
import { UsageSection } from "./usage-section";
|
||||
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
|
||||
import {
|
||||
RevokeRuntimeAccessDialog,
|
||||
parseRuntimeAccessRevocationPlan,
|
||||
type RuntimeAccessRevocationPlan,
|
||||
} from "./revoke-runtime-access-dialog";
|
||||
import { RuntimeAccessEditor, RuntimeAccessReadout } from "./runtime-access-editor";
|
||||
import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog";
|
||||
import { runtimeRowLabel } from "./runtime-machines";
|
||||
import { useT, useTimeAgo } from "../../i18n";
|
||||
@@ -508,9 +502,9 @@ function DiagnosticsCard({
|
||||
{t(($) => $.detail.diagnostics_visibility)}
|
||||
</div>
|
||||
{canEditVisibility ? (
|
||||
<VisibilityEditor runtime={runtime} />
|
||||
<RuntimeAccessEditor runtime={runtime} />
|
||||
) : (
|
||||
<VisibilityReadout runtime={runtime} />
|
||||
<RuntimeAccessReadout runtime={runtime} />
|
||||
)}
|
||||
</div>
|
||||
{canDelete && (
|
||||
@@ -536,144 +530,3 @@ function DiagnosticsCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VisibilityReadout renders a static "Private" / "Public" pill for everyone
|
||||
// who is not the runtime owner — workspace admins included (MUL-6126). Its
|
||||
// tooltip is phrased in the third person for that reason; the editor's own
|
||||
// hints stay in the second person. The description used to sit under the
|
||||
// chip; it now lives in the hover tooltip so the Diagnostics column stays
|
||||
// compact and matches the surrounding sections. 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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2 py-1.5 text-caption">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t(($) => $.detail.visibility_label[visibility])}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(($) => $.detail.visibility_hint_readonly[visibility])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// VisibilityEditor lets the runtime owner flip public↔private. Owner only —
|
||||
// the PATCH endpoint refuses a workspace admin here (canSetRuntimeVisibility);
|
||||
// this is a UI gate, not a security boundary. Per-choice description text lives in the hover
|
||||
// tooltip so the two buttons stay a tight icon+label pair instead of the
|
||||
// previous two-line block that competed with the surrounding cards.
|
||||
function VisibilityEditor({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
const wsId = useWorkspaceId();
|
||||
const updateRuntime = useUpdateRuntime(wsId);
|
||||
const current = runtime.visibility === "public" ? "public" : "private";
|
||||
const [revokePlan, setRevokePlan] = useState<RuntimeAccessRevocationPlan | null>(null);
|
||||
const [revokeOpen, setRevokeOpen] = useState(false);
|
||||
|
||||
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: (err) => {
|
||||
const plan = parseRuntimeAccessRevocationPlan(err);
|
||||
if (plan) {
|
||||
setRevokePlan(plan);
|
||||
setRevokeOpen(true);
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.detail.visibility_toast_failed),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<VisibilityChoice
|
||||
active={current === "private"}
|
||||
icon={<Lock className="h-3 w-3" />}
|
||||
label={t(($) => $.detail.visibility_label.private)}
|
||||
tooltip={t(($) => $.detail.visibility_hint.private)}
|
||||
disabled={updateRuntime.isPending}
|
||||
onClick={() => flip("private")}
|
||||
/>
|
||||
<VisibilityChoice
|
||||
active={current === "public"}
|
||||
icon={<Globe className="h-3 w-3" />}
|
||||
label={t(($) => $.detail.visibility_label.public)}
|
||||
tooltip={t(($) => $.detail.visibility_hint.public)}
|
||||
disabled={updateRuntime.isPending}
|
||||
onClick={() => flip("public")}
|
||||
/>
|
||||
</div>
|
||||
<RevokeRuntimeAccessDialog
|
||||
open={revokeOpen}
|
||||
onOpenChange={setRevokeOpen}
|
||||
runtime={runtime}
|
||||
wsId={wsId}
|
||||
initialPlan={revokePlan}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibilityChoice({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
tooltip,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xs px-2 py-1 text-caption font-medium transition-colors ${
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Users,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -262,19 +263,21 @@ function PendingRuntimeBadge({ runtime }: { runtime: AgentRuntime }) {
|
||||
// `🔒 Private` chip on every row turns the whole column into noise.
|
||||
function VisibilityBadge({ runtime }: { runtime: AgentRuntime }) {
|
||||
const { t } = useT("runtimes");
|
||||
if (runtime.visibility !== "public") return null;
|
||||
const scope = runtime.visibility === "public" ? "public" : "members";
|
||||
if (scope === "members" && !runtime.shared_with?.length) return null;
|
||||
const Icon = scope === "public" ? Globe : Users;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-xs bg-info/10 px-1 text-micro font-medium text-info">
|
||||
<Globe className="h-2.5 w-2.5" />
|
||||
{t(($) => $.detail.visibility_label.public)}
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{t(($) => $.detail.visibility_label[scope])}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(($) => $.detail.visibility_hint.public)}
|
||||
{t(($) => $.detail.visibility_hint_readonly[scope])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1108,10 +1108,10 @@ func (h *Handler) loadAgentRuntimeAvailability(ctx context.Context, agents []db.
|
||||
continue
|
||||
}
|
||||
// ListAgentRuntimes exposes every row to workspace owner/admin and only
|
||||
// owner/public rows to regular members. Keep the coarse bridge for the
|
||||
// owner/shared/public rows to regular members. Keep the coarse bridge for the
|
||||
// rows that the viewer's runtime list cannot carry.
|
||||
if runtime.Visibility == "public" ||
|
||||
(runtime.OwnerID.Valid && uuidToString(runtime.OwnerID) == viewerID) {
|
||||
service.RuntimeAllowsUser(runtime, parseUUID(viewerID)) {
|
||||
continue
|
||||
}
|
||||
result[uuidToString(runtime.ID)] = deriveAgentRuntimeAvailability(runtime, now)
|
||||
@@ -1467,7 +1467,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner can create agents on it")
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before creating agents")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1610,7 +1610,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner can create agents on it")
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before creating agents")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2033,14 +2033,14 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner can move agents onto it")
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before moving agents onto it")
|
||||
return
|
||||
}
|
||||
// Task delivery authorizes private runtimes against the agent owner,
|
||||
// not the member making this update. Admin management permission must
|
||||
// not save a binding that the existing delivery gate will reject.
|
||||
if runtime.Visibility == "private" && runtime.OwnerID != existing.OwnerID {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; its owner must match the agent owner")
|
||||
if runtime.Visibility == "private" && !service.RuntimeAllowsUser(runtime, existing.OwnerID) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; its owner must grant the agent owner access")
|
||||
return
|
||||
}
|
||||
params.RuntimeID = runtime.ID
|
||||
@@ -2371,7 +2371,11 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner can move agents onto it")
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access before moving agents onto it")
|
||||
return
|
||||
}
|
||||
if runtime.Visibility == "private" && !service.RuntimeAllowsUser(runtime, existing.OwnerID) {
|
||||
writeError(w, http.StatusForbidden, "the agent owner does not have access to this runtime")
|
||||
return
|
||||
}
|
||||
params.RuntimeMode = pgtype.Text{String: runtime.RuntimeMode, Valid: true}
|
||||
|
||||
@@ -353,7 +353,7 @@ func (h *Handler) SaveAgentBuilderDraft(w http.ResponseWriter, r *http.Request)
|
||||
// resolveBuilderRuntime loads a runtime the caller is allowed to execute a
|
||||
// builder conversation on. Shared by session create and runtime switch so both
|
||||
// enforce the same three gates in the same order: it exists in this workspace,
|
||||
// this member may use it (private runtimes stay owner/admin-only), and it is
|
||||
// this member may use it (private runtimes require owner consent), and it is
|
||||
// online. verb names the attempted action in the offline error so the two call
|
||||
// sites read naturally.
|
||||
func (h *Handler) resolveBuilderRuntime(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID pgtype.UUID, runtimeID, verb string) (db.AgentRuntime, bool) {
|
||||
@@ -381,7 +381,7 @@ func (h *Handler) authorizeBuilderRuntime(w http.ResponseWriter, r *http.Request
|
||||
return false
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner can use it")
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; ask its owner to grant you access")
|
||||
return false
|
||||
}
|
||||
if runtime.Status != "online" {
|
||||
|
||||
@@ -852,6 +852,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerID: prow.OwnerID,
|
||||
LegacyDaemonID: prow.LegacyDaemonID,
|
||||
Visibility: prow.Visibility,
|
||||
SharedWith: prow.SharedWith,
|
||||
ProfileID: prow.ProfileID,
|
||||
}
|
||||
} else {
|
||||
@@ -905,6 +906,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerID: row.OwnerID,
|
||||
LegacyDaemonID: row.LegacyDaemonID,
|
||||
Visibility: row.Visibility,
|
||||
SharedWith: row.SharedWith,
|
||||
ProfileID: row.ProfileID,
|
||||
}
|
||||
}
|
||||
@@ -2464,8 +2466,7 @@ func (h *Handler) finalizeClaimDelivery(
|
||||
Detail: "agent runtime changed before delivery",
|
||||
}
|
||||
}
|
||||
if locked.Visibility == "private" && locked.OwnerID.Valid &&
|
||||
(!agent.OwnerID.Valid || agent.OwnerID != locked.OwnerID) {
|
||||
if locked.Visibility == "private" && locked.OwnerID.Valid && !service.RuntimeAllowsUser(locked, agent.OwnerID) {
|
||||
agentOwnerID = agent.OwnerID
|
||||
return &service.ClaimDeliveryAuthzError{
|
||||
Reason: "error_runtime_access_denied",
|
||||
@@ -2883,8 +2884,7 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu
|
||||
"error_agent_runtime_changed", http.StatusConflict, "agent runtime changed before task delivery",
|
||||
)
|
||||
}
|
||||
if runtime.Visibility == "private" && runtime.OwnerID.Valid &&
|
||||
(!agent.OwnerID.Valid || agent.OwnerID != runtime.OwnerID) {
|
||||
if runtime.Visibility == "private" && runtime.OwnerID.Valid && !service.RuntimeAllowsUser(runtime, agent.OwnerID) {
|
||||
userMessage := "This private runtime cannot run the assigned agent because the agent and runtime have different owners."
|
||||
if !agent.OwnerID.Valid {
|
||||
userMessage = "This private runtime cannot run the assigned agent because the agent has no owner."
|
||||
|
||||
+121
-190
@@ -21,7 +21,6 @@ import (
|
||||
"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"
|
||||
"github.com/multica-ai/multica/server/pkg/taskfailure"
|
||||
)
|
||||
|
||||
type AgentRuntimeResponse struct {
|
||||
@@ -40,10 +39,11 @@ type AgentRuntimeResponse struct {
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
// Visibility is "private" (default — only the owner can bind agents) or
|
||||
// Visibility is "private" (owner plus explicit SharedWith grants) or
|
||||
// "public" (any workspace member can). See migration 083 and
|
||||
// canUseRuntimeForAgent.
|
||||
Visibility string `json:"visibility"`
|
||||
Visibility string `json:"visibility"`
|
||||
SharedWith []string `json:"shared_with"`
|
||||
// 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"`
|
||||
@@ -75,6 +75,7 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
Visibility: rt.Visibility,
|
||||
SharedWith: runtimeSharedWithStrings(rt.SharedWith),
|
||||
ProfileID: uuidToPtr(rt.ProfileID),
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
@@ -460,11 +461,14 @@ func (h *Handler) resolveViewingTZ(r *http.Request) string {
|
||||
// Only fields users may legitimately edit are listed; other runtime metadata
|
||||
// (provider, daemon_id, status…) flows in from the daemon and is read-only here.
|
||||
type UpdateAgentRuntimeRequest struct {
|
||||
// Visibility flips a runtime between "private" (default — only the owner
|
||||
// can bind agents) and "public" (any workspace member can). Runtime owner
|
||||
// Visibility selects "private" (owner plus SharedWith grants) or
|
||||
// "public" (any workspace member can). Runtime owner
|
||||
// only, gated by canSetRuntimeVisibility — narrower than the rest of this
|
||||
// request, which admins may also send.
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
// SharedWith replaces explicit workspace user grants while visibility is private.
|
||||
SharedWith *[]string `json:"shared_with,omitempty"`
|
||||
sharedWithIDs []pgtype.UUID
|
||||
// CustomName sets or clears a user-facing display override (MUL-4217).
|
||||
// An empty / whitespace-only string clears it (revert to the
|
||||
// daemon-proposed name). Owner / workspace admin only.
|
||||
@@ -514,34 +518,18 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate every field before any mutation so a bad value in one field
|
||||
// can't leave a partially-applied PATCH.
|
||||
var (
|
||||
newVisibility string
|
||||
needVisibility bool
|
||||
)
|
||||
if req.Visibility != nil {
|
||||
v := *req.Visibility
|
||||
if v != "private" && v != "public" {
|
||||
writeError(w, http.StatusBadRequest, "visibility must be 'private' or 'public'")
|
||||
if req.SharedWith != nil {
|
||||
req.sharedWithIDs, ok = parseRuntimeSharedWith(w, *req.SharedWith)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Only a real change is gated. An admin renaming a runtime through a
|
||||
// PATCH-as-PUT client that echoes the unchanged visibility back is not
|
||||
// trying to share anyone's machine, so that no-op is dropped rather
|
||||
// than turned into a 403 — same tolerance UpdateAgent applies to
|
||||
// resubmitted agent permissions.
|
||||
if v != rt.Visibility {
|
||||
if !canSetRuntimeVisibility(member, rt) {
|
||||
writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility")
|
||||
return
|
||||
}
|
||||
if h.refuseQoderManagedRuntime(w, r.Context(), rt, "the Qoder runtime is always shared with the workspace") {
|
||||
return
|
||||
}
|
||||
newVisibility = v
|
||||
needVisibility = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate every field before any mutation so a bad value in one field
|
||||
// can't leave a partially-applied PATCH.
|
||||
if req.Visibility != nil && *req.Visibility != "private" && *req.Visibility != "public" {
|
||||
writeError(w, http.StatusBadRequest, "visibility must be 'private' or 'public'")
|
||||
return
|
||||
}
|
||||
if req.CustomName != nil {
|
||||
if len([]rune(strings.TrimSpace(*req.CustomName))) > maxRuntimeCustomNameLen {
|
||||
@@ -552,18 +540,22 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
changed := false
|
||||
|
||||
// The no-dependencies fast path must lock and re-check before changing
|
||||
// visibility. A plain read followed by UPDATE would leave a narrow window
|
||||
// where another member could bind an agent to the still-public runtime.
|
||||
if needVisibility && rt.Visibility == "public" && newVisibility == "private" {
|
||||
updated, plan, didChange, err := h.makeRuntimePrivateIfNoWorkspaceDependencies(r.Context(), rt, member)
|
||||
if req.Visibility != nil || req.SharedWith != nil {
|
||||
updated, plan, didChange, err := h.updateRuntimeAccessIfNoDependencies(r.Context(), rt, member, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuntimeOwnerChanged) {
|
||||
writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility")
|
||||
if writeRuntimeRevocationBusy(w, err) {
|
||||
return
|
||||
}
|
||||
slog.Error("make runtime private without dependencies failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to check runtime dependencies")
|
||||
if errors.Is(err, errRuntimeOwnerChanged) {
|
||||
writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility")
|
||||
} else if errors.Is(err, errRuntimeManagedByConnection) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "the Qoder runtime is always shared with the workspace", "code": "runtime_managed_by_connection"})
|
||||
} else if errors.Is(err, errInvalidRuntimeShare) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
slog.Error("update runtime access failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runtime access")
|
||||
}
|
||||
return
|
||||
}
|
||||
if plan.hasDependencies() {
|
||||
@@ -571,24 +563,9 @@ func (h *Handler) UpdateAgentRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rt = updated
|
||||
needVisibility = false
|
||||
changed = didChange
|
||||
}
|
||||
|
||||
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
|
||||
changed = true
|
||||
}
|
||||
|
||||
if req.CustomName != nil {
|
||||
// An empty / whitespace-only name clears the override (NULL), so the
|
||||
// runtime falls back to its daemon-proposed Name.
|
||||
@@ -682,7 +659,7 @@ func (h *Handler) runtimeLookup(source string) service.RuntimeLookup {
|
||||
}
|
||||
|
||||
// runtimeWorkspaceAccessRevocationPlan is the owner-visible snapshot of work
|
||||
// that a public-to-private transition must revoke. It intentionally excludes
|
||||
// that an access restriction must revoke. It intentionally excludes
|
||||
// the runtime owner's agents. Hidden Builder agents owned by teammates also
|
||||
// represent work borrowing the owner's machine and must lose access.
|
||||
type runtimeWorkspaceAccessRevocationPlan struct {
|
||||
@@ -708,14 +685,13 @@ func (p runtimeWorkspaceAccessRevocationPlan) hasDependencies() bool {
|
||||
return len(p.Agents) > 0 || len(p.Tasks) > 0
|
||||
}
|
||||
|
||||
// makeRuntimePrivateIfNoWorkspaceDependencies handles the non-disruptive
|
||||
// public-to-private path. Binding writers take the same runtime lock and
|
||||
// recheck visibility before inserting/updating their agent.
|
||||
// updateRuntimeAccessIfNoDependencies applies grants without disrupting work.
|
||||
// Binding writers take the same runtime lock and recheck access.
|
||||
//
|
||||
// A non-empty plan is deliberately returned without committing. The caller
|
||||
// turns it into the same confirmation response used by the disruptive path;
|
||||
// the confirmed endpoint will take a fresh locked snapshot before revoking.
|
||||
func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Context, rt db.AgentRuntime, member db.Member) (db.AgentRuntime, runtimeWorkspaceAccessRevocationPlan, bool, error) {
|
||||
func (h *Handler) updateRuntimeAccessIfNoDependencies(ctx context.Context, rt db.AgentRuntime, member db.Member, req UpdateAgentRuntimeRequest) (db.AgentRuntime, runtimeWorkspaceAccessRevocationPlan, bool, error) {
|
||||
tx, err := h.TxStarter.Begin(ctx)
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("begin transaction: %w", err)
|
||||
@@ -727,37 +703,51 @@ func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Contex
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("lock runtime: %w", err)
|
||||
}
|
||||
next, err := resolveRuntimeAccess(ctx, qtx, lockedRuntime, req)
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, err
|
||||
}
|
||||
if sameRuntimeAccess(lockedRuntime, next) {
|
||||
return lockedRuntime, runtimeWorkspaceAccessRevocationPlan{}, false, nil
|
||||
}
|
||||
if !canSetRuntimeVisibility(member, lockedRuntime) {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, errRuntimeOwnerChanged
|
||||
}
|
||||
// Another request may have made the runtime private while this PATCH was
|
||||
// waiting for its lock. The requested end state is already satisfied.
|
||||
if lockedRuntime.Visibility != "public" {
|
||||
return lockedRuntime, runtimeWorkspaceAccessRevocationPlan{}, false, nil
|
||||
managed, err := h.qoderManagesRuntime(ctx, lockedRuntime)
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, err
|
||||
}
|
||||
if managed {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, errRuntimeManagedByConnection
|
||||
}
|
||||
|
||||
agents, err := qtx.ListNonOwnerAgentsByRuntime(ctx, db.ListNonOwnerAgentsByRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner agents: %w", err)
|
||||
}
|
||||
tasks, err := qtx.ListNonOwnerActiveTasksByRuntime(ctx, db.ListNonOwnerActiveTasksByRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner tasks: %w", err)
|
||||
}
|
||||
plan := runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks}
|
||||
if plan.hasDependencies() {
|
||||
return lockedRuntime, plan, false, nil
|
||||
if runtimeAccessNarrows(lockedRuntime, next) {
|
||||
agents, err := qtx.ListNonOwnerAgentsByRuntime(ctx, db.ListNonOwnerAgentsByRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner agents: %w", err)
|
||||
}
|
||||
tasks, err := qtx.ListNonOwnerActiveTasksByRuntime(ctx, db.ListNonOwnerActiveTasksByRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("list non-owner tasks: %w", err)
|
||||
}
|
||||
plan := runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks}
|
||||
if plan.hasDependencies() {
|
||||
return lockedRuntime, plan, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{
|
||||
ID: lockedRuntime.ID,
|
||||
Visibility: "private",
|
||||
Visibility: next.Visibility,
|
||||
SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("update runtime visibility: %w", err)
|
||||
@@ -765,7 +755,7 @@ func (h *Handler) makeRuntimePrivateIfNoWorkspaceDependencies(ctx context.Contex
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return db.AgentRuntime{}, runtimeWorkspaceAccessRevocationPlan{}, false, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return updated, plan, true, nil
|
||||
return updated, runtimeWorkspaceAccessRevocationPlan{}, true, nil
|
||||
}
|
||||
|
||||
// runtimeWorkspaceAccessRevocationResponse is returned for both the initial
|
||||
@@ -789,7 +779,7 @@ func (h *Handler) runtimeWorkspaceAccessRevocationResponse(plan runtimeWorkspace
|
||||
taskIDs[i] = uuidToString(task.ID)
|
||||
}
|
||||
return map[string]any{
|
||||
"error": "making this runtime private will unbind teammates' agents, cancel their active runs, and pause their active autopilots; review and confirm first.",
|
||||
"error": "restricting runtime access will unbind affected agents, cancel their active runs, and pause their active autopilots; review and confirm first.",
|
||||
"code": code,
|
||||
"nonowner_agents": agents,
|
||||
"active_task_ids": taskIDs,
|
||||
@@ -799,7 +789,7 @@ func (h *Handler) runtimeWorkspaceAccessRevocationResponse(plan runtimeWorkspace
|
||||
// requireRuntimeReadAccess protects runtime data and machine-triggering
|
||||
// capabilities. Governance access is deliberately separate: workspace owners
|
||||
// and admins may list, rename, or delete another member's private runtime,
|
||||
// but a private machine is readable or usable only by its owner. Returning
|
||||
// but a private machine is readable or usable only with its owner's consent. Returning
|
||||
// 404 for that case prevents a known runtime ID from becoming an oracle.
|
||||
//
|
||||
// source names the product behaviour behind the read for
|
||||
@@ -1001,35 +991,16 @@ func (h *Handler) profileInstanceRefusalBlockers(ctx context.Context, runtimeID
|
||||
return profileInstanceBlockers{agents: agents, undrainedTasks: tasks, known: true}
|
||||
}
|
||||
|
||||
// canUseRuntimeForAgent reports whether a workspace member is allowed to
|
||||
// bind a new agent to — or move an existing agent onto — the given runtime.
|
||||
// A `public` runtime is usable by anyone in the workspace; a `private` one is
|
||||
// usable only by its owner, with NO workspace owner/admin override (MUL-6126):
|
||||
// a private runtime is someone's own machine, and running an agent on it
|
||||
// spends their credentials and their local files, which is not an
|
||||
// administrative decision. Sharing it is the owner's call, which is why
|
||||
// canSetRuntimeVisibility is owner-only too — otherwise an admin would keep
|
||||
// the same override with one extra step.
|
||||
//
|
||||
// An ownerless runtime is refused whatever its visibility: task claim needs an
|
||||
// owner to mint the agent's task token and cancels the task without one
|
||||
// (MUL-3292), so binding to one only produces agents that fail at run time.
|
||||
//
|
||||
// This is the same rule the clients enforce (`isRuntimeUsableForUser` in
|
||||
// packages/core/runtimes/access.ts), so UI, API and CLI agree. See migration
|
||||
// 083 for the visibility column.
|
||||
// canUseRuntimeForAgent checks runtime-owner consent after workspace membership.
|
||||
// Private runtimes admit their owner and SharedWith recipients; public runtimes
|
||||
// admit every workspace member. Workspace roles never override this decision.
|
||||
// An ownerless runtime cannot mint task tokens and remains unusable.
|
||||
func canUseRuntimeForAgent(member db.Member, rt db.AgentRuntime) bool {
|
||||
if !rt.OwnerID.Valid {
|
||||
return false
|
||||
}
|
||||
if rt.Visibility == "public" {
|
||||
return true
|
||||
}
|
||||
return uuidToString(rt.OwnerID) == uuidToString(member.UserID)
|
||||
return service.RuntimeAllowsUser(rt, member.UserID)
|
||||
}
|
||||
|
||||
// canSetRuntimeVisibility reports whether a member may flip this runtime
|
||||
// between `private` and `public`. Owner-only, and deliberately narrower than
|
||||
// between `private`, selected members, and `public`. Deliberately narrower than
|
||||
// canEditRuntime: visibility is the owner's consent to lend their machine to
|
||||
// the workspace, so an admin who could flip it would still hold the override
|
||||
// canUseRuntimeForAgent removed. Admins keep canEditRuntime for the
|
||||
@@ -1042,10 +1013,11 @@ func canSetRuntimeVisibility(member db.Member, rt db.AgentRuntime) bool {
|
||||
type revokeRuntimeWorkspaceAccessRequest struct {
|
||||
ExpectedNonownerAgentIDs []string `json:"expected_nonowner_agent_ids"`
|
||||
ExpectedTaskIDs []string `json:"expected_task_ids"`
|
||||
SharedWith []string `json:"shared_with"`
|
||||
}
|
||||
|
||||
// RevokeRuntimeWorkspaceAccess confirms the disruptive half of changing a
|
||||
// public runtime back to private. The normal PATCH endpoint remains a direct
|
||||
// RevokeRuntimeWorkspaceAccess confirms a restriction to private or selected
|
||||
// members. Omitted shared_with preserves the old owner-only request contract. The normal PATCH endpoint remains a direct
|
||||
// update when no teammate is using the runtime; otherwise it returns a
|
||||
// structured 409 and this endpoint applies the reviewed plan atomically.
|
||||
//
|
||||
@@ -1065,6 +1037,11 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
sharedWithIDs, ok := parseRuntimeSharedWith(w, req.SharedWith)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
expectedAgents, ok := parseExpectedUUIDIDs(req.ExpectedNonownerAgentIDs)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "expected_nonowner_agent_ids must be a list of valid UUIDs")
|
||||
@@ -1110,103 +1087,57 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusForbidden, "only the runtime owner can change its visibility")
|
||||
return
|
||||
}
|
||||
if lockedRuntime.Visibility != "public" {
|
||||
private := "private"
|
||||
next, err := resolveRuntimeAccess(r.Context(), qtx, lockedRuntime, UpdateAgentRuntimeRequest{Visibility: &private, SharedWith: &req.SharedWith, sharedWithIDs: sharedWithIDs})
|
||||
if err != nil {
|
||||
if writeRuntimeRevocationBusy(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errInvalidRuntimeShare) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "failed to validate runtime access")
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.refuseQoderManagedRuntime(w, r.Context(), lockedRuntime, "the Qoder runtime is always shared with the workspace") {
|
||||
return
|
||||
}
|
||||
if sameRuntimeAccess(lockedRuntime, next) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "this runtime is already private; refresh before trying again.",
|
||||
"error": "runtime access already matches; refresh before trying again.",
|
||||
"code": "runtime_access_revocation_not_required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
currentAgents, err := qtx.ListNonOwnerAgentsByRuntimeForUpdate(r.Context(), db.ListNonOwnerAgentsByRuntimeForUpdateParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
})
|
||||
plan, err := lockRuntimeRevocationPlan(r.Context(), qtx, next)
|
||||
if err != nil {
|
||||
if writeRuntimeRevocationBusy(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to lock runtime agents")
|
||||
return
|
||||
}
|
||||
if _, err := qtx.LockRuntimeRevocationChatSessions(r.Context(), db.LockRuntimeRevocationChatSessionsParams{
|
||||
RuntimeID: lockedRuntime.ID, RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
}); err != nil {
|
||||
if !writeRuntimeRevocationBusy(w, err) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to lock runtime chats")
|
||||
writeError(w, http.StatusInternalServerError, "failed to lock runtime dependencies")
|
||||
}
|
||||
return
|
||||
}
|
||||
currentTasks, err := qtx.ListNonOwnerActiveTasksByRuntimeForUpdate(r.Context(), db.ListNonOwnerActiveTasksByRuntimeForUpdateParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to lock runtime tasks")
|
||||
return
|
||||
}
|
||||
plan := runtimeWorkspaceAccessRevocationPlan{Agents: currentAgents, Tasks: currentTasks}
|
||||
if !agentSetMatches(currentAgents, expectedAgents) || !taskSetMatches(currentTasks, expectedTasks) {
|
||||
|
||||
if !agentSetMatches(plan.Agents, expectedAgents) || !taskSetMatches(plan.Tasks, expectedTasks) {
|
||||
body := h.runtimeWorkspaceAccessRevocationResponse(plan, "runtime_access_revocation_plan_changed")
|
||||
body["error"] = "the runtime access plan changed; please review and confirm again."
|
||||
writeJSON(w, http.StatusConflict, body)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := qtx.UpdateAgentRuntimeVisibility(r.Context(), db.UpdateAgentRuntimeVisibilityParams{
|
||||
ID: lockedRuntime.ID,
|
||||
Visibility: "private",
|
||||
})
|
||||
updated, result, readyChats, err := applyRuntimeAccessRevocation(r.Context(), qtx, next)
|
||||
if err != nil {
|
||||
slog.Error("make runtime private failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runtime")
|
||||
return
|
||||
}
|
||||
cancelled, err := qtx.CancelNonOwnerTasksByRuntime(r.Context(), db.CancelNonOwnerTasksByRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
FailureReason: string(taskfailure.ReasonRuntimeAccessRevoked),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("cancel non-owner runtime tasks failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to cancel runtime tasks")
|
||||
return
|
||||
}
|
||||
readyChats, err := service.PrepareRuntimeAccessCancellation(r.Context(), qtx, cancelled)
|
||||
if err != nil {
|
||||
slog.Error("prepare runtime cancellation chat settlement failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to settle runtime chats")
|
||||
return
|
||||
}
|
||||
unbound, err := qtx.UnbindNonOwnerAgentsFromRuntime(r.Context(), db.UnbindNonOwnerAgentsFromRuntimeParams{
|
||||
RuntimeID: lockedRuntime.ID,
|
||||
RuntimeOwnerID: lockedRuntime.OwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("unbind non-owner runtime agents failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to unbind runtime agents")
|
||||
return
|
||||
}
|
||||
unboundIDs := make([]pgtype.UUID, len(unbound))
|
||||
for i, agent := range unbound {
|
||||
unboundIDs[i] = agent.ID
|
||||
}
|
||||
paused, err := qtx.PauseAutopilotsByUnboundAgents(r.Context(), unboundIDs)
|
||||
if err != nil {
|
||||
slog.Error("pause autopilots for runtime privacy failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to pause affected autopilots")
|
||||
slog.Error("revoke runtime access failed", "error", err, "runtime_id", runtimeID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to revoke runtime access")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit runtime privacy change")
|
||||
return
|
||||
}
|
||||
|
||||
result := service.RuntimeTeardownResult{
|
||||
UnboundAgents: unbound,
|
||||
CancelledTasks: cancelled,
|
||||
PausedAutopilots: paused,
|
||||
}
|
||||
h.PublishRuntimeTeardown(r.Context(), result, wsID, "member", uuidToString(member.UserID), "update", true)
|
||||
for _, taskID := range readyChats {
|
||||
h.TaskService.FinalizeDeferredCancelledChat(r.Context(), taskID)
|
||||
@@ -1214,16 +1145,16 @@ func (h *Handler) RevokeRuntimeWorkspaceAccess(w http.ResponseWriter, r *http.Re
|
||||
slog.Info("runtime made private and workspace access revoked",
|
||||
"runtime_id", runtimeID,
|
||||
"changed_by", uuidToString(member.UserID),
|
||||
"agents_unbound", len(unbound),
|
||||
"tasks_cancelled", len(cancelled),
|
||||
"autopilots_paused", len(paused),
|
||||
"agents_unbound", len(result.UnboundAgents),
|
||||
"tasks_cancelled", len(result.CancelledTasks),
|
||||
"autopilots_paused", len(result.PausedAutopilots),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runtime": runtimeToResponse(updated),
|
||||
"agents_unbound": len(unbound),
|
||||
"tasks_cancelled": len(cancelled),
|
||||
"autopilots_paused": len(paused),
|
||||
"agents_unbound": len(result.UnboundAgents),
|
||||
"tasks_cancelled": len(result.CancelledTasks),
|
||||
"autopilots_paused": len(result.PausedAutopilots),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/taskfailure"
|
||||
)
|
||||
|
||||
var errRuntimeManagedByConnection = errors.New("runtime managed by connection")
|
||||
|
||||
var errInvalidRuntimeShare = errors.New("invalid runtime sharing")
|
||||
|
||||
func parseRuntimeSharedWith(w http.ResponseWriter, raw []string) ([]pgtype.UUID, bool) {
|
||||
if len(raw) > 1000 {
|
||||
writeError(w, http.StatusBadRequest, "shared_with has too many members")
|
||||
return nil, false
|
||||
}
|
||||
ids := make([]pgtype.UUID, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
id, ok := parseUUIDOrBadRequest(w, value, "shared_with")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
func runtimeAccessNarrows(previous, next db.AgentRuntime) bool {
|
||||
if next.Visibility == "public" {
|
||||
return false
|
||||
}
|
||||
if previous.Visibility == "public" {
|
||||
return true
|
||||
}
|
||||
for _, id := range previous.SharedWith {
|
||||
if id != next.OwnerID && !slices.Contains(next.SharedWith, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runtimeSharedWithStrings(ids []pgtype.UUID) []string {
|
||||
result := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
result[i] = uuidToString(id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sameRuntimeAccess(a, b db.AgentRuntime) bool {
|
||||
return a.Visibility == b.Visibility && slices.Equal(a.SharedWith, b.SharedWith)
|
||||
}
|
||||
|
||||
// Resolve PATCH fields from the locked row. Explicit private/public requests
|
||||
// without a recipient list clear grants, preserving old clients' privacy action.
|
||||
func resolveRuntimeAccess(ctx context.Context, q *db.Queries, rt db.AgentRuntime, req UpdateAgentRuntimeRequest) (db.AgentRuntime, error) {
|
||||
next := rt
|
||||
if req.Visibility != nil {
|
||||
next.Visibility = *req.Visibility
|
||||
next.SharedWith = []pgtype.UUID{}
|
||||
}
|
||||
if req.SharedWith != nil {
|
||||
next.SharedWith = []pgtype.UUID{}
|
||||
for _, id := range req.sharedWithIDs {
|
||||
if id == rt.OwnerID || slices.Contains(next.SharedWith, id) {
|
||||
continue
|
||||
}
|
||||
next.SharedWith = append(next.SharedWith, id)
|
||||
}
|
||||
sort.Slice(next.SharedWith, func(i, j int) bool { return uuidToString(next.SharedWith[i]) < uuidToString(next.SharedWith[j]) })
|
||||
}
|
||||
if next.Visibility == "public" && len(next.SharedWith) > 0 {
|
||||
return next, fmt.Errorf("%w: shared_with requires private visibility", errInvalidRuntimeShare)
|
||||
}
|
||||
if len(next.SharedWith) > 0 {
|
||||
members, err := q.LockRuntimeShareMembers(ctx, db.LockRuntimeShareMembersParams{WorkspaceID: rt.WorkspaceID, UserIds: next.SharedWith})
|
||||
if err != nil {
|
||||
return next, err
|
||||
}
|
||||
if len(members) != len(next.SharedWith) {
|
||||
return next, fmt.Errorf("%w: every recipient must be a workspace member", errInvalidRuntimeShare)
|
||||
}
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// Both explicit access changes and workspace departure use the same lock order
|
||||
// and cancellation settlement, inside their caller's transaction.
|
||||
func lockRuntimeRevocationPlan(ctx context.Context, qtx *db.Queries, next db.AgentRuntime) (runtimeWorkspaceAccessRevocationPlan, error) {
|
||||
agents, err := qtx.ListNonOwnerAgentsByRuntimeForUpdate(ctx, db.ListNonOwnerAgentsByRuntimeForUpdateParams{
|
||||
RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return runtimeWorkspaceAccessRevocationPlan{}, err
|
||||
}
|
||||
if _, err := qtx.LockRuntimeRevocationChatSessions(ctx, db.LockRuntimeRevocationChatSessionsParams{
|
||||
RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith,
|
||||
}); err != nil {
|
||||
return runtimeWorkspaceAccessRevocationPlan{}, err
|
||||
}
|
||||
tasks, err := qtx.ListNonOwnerActiveTasksByRuntimeForUpdate(ctx, db.ListNonOwnerActiveTasksByRuntimeForUpdateParams{
|
||||
RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith,
|
||||
})
|
||||
return runtimeWorkspaceAccessRevocationPlan{Agents: agents, Tasks: tasks}, err
|
||||
}
|
||||
|
||||
func applyRuntimeAccessRevocation(ctx context.Context, qtx *db.Queries, next db.AgentRuntime) (db.AgentRuntime, service.RuntimeTeardownResult, []pgtype.UUID, error) {
|
||||
var result service.RuntimeTeardownResult
|
||||
updated, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{
|
||||
ID: next.ID, Visibility: next.Visibility, SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return updated, result, nil, err
|
||||
}
|
||||
result.CancelledTasks, err = qtx.CancelNonOwnerTasksByRuntime(ctx, db.CancelNonOwnerTasksByRuntimeParams{
|
||||
RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith,
|
||||
FailureReason: string(taskfailure.ReasonRuntimeAccessRevoked),
|
||||
})
|
||||
if err != nil {
|
||||
return updated, result, nil, err
|
||||
}
|
||||
readyChats, err := service.PrepareRuntimeAccessCancellation(ctx, qtx, result.CancelledTasks)
|
||||
if err != nil {
|
||||
return updated, result, nil, err
|
||||
}
|
||||
result.UnboundAgents, err = qtx.UnbindNonOwnerAgentsFromRuntime(ctx, db.UnbindNonOwnerAgentsFromRuntimeParams{
|
||||
RuntimeID: next.ID, RuntimeOwnerID: next.OwnerID, SharedWith: next.SharedWith,
|
||||
})
|
||||
if err != nil {
|
||||
return updated, result, nil, err
|
||||
}
|
||||
ids := make([]pgtype.UUID, len(result.UnboundAgents))
|
||||
for i, a := range result.UnboundAgents {
|
||||
ids[i] = a.ID
|
||||
}
|
||||
result.PausedAutopilots, err = qtx.PauseAutopilotsByUnboundAgents(ctx, ids)
|
||||
return updated, result, readyChats, err
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/testutil"
|
||||
)
|
||||
|
||||
func runtimeSharingPatch(t *testing.T, runtimeID, actorID string, body map[string]any) *testutil.Response {
|
||||
t.Helper()
|
||||
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, body)
|
||||
return testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", runtimeID))
|
||||
}
|
||||
|
||||
func TestRuntimeSharingAccess(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Fatal("database-backed fixture required")
|
||||
}
|
||||
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
||||
var runtime AgentRuntimeResponse
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID, memberID, ownerID}}).Want(http.StatusOK).JSON(&runtime)
|
||||
if runtime.Visibility != "private" || len(runtime.SharedWith) != 1 || runtime.SharedWith[0] != memberID {
|
||||
t.Fatalf("unexpected grant: %+v", runtime)
|
||||
}
|
||||
var visible []AgentRuntimeResponse
|
||||
testutil.Call(t, testHandler.ListAgentRuntimes, newRequestAs(memberID, http.MethodGet, "/api/runtimes", nil)).Want(http.StatusOK).JSON(&visible)
|
||||
found := false
|
||||
for _, rt := range visible {
|
||||
found = found || rt.ID == rtID
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("shared runtime missing from recipient list")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
actor string
|
||||
status int
|
||||
}{{ownerID, 200}, {memberID, 200}, {testUserID, 404}} {
|
||||
req := newRequestAs(tc.actor, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil)
|
||||
testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(tc.status)
|
||||
}
|
||||
for _, actor := range []string{memberID, testUserID} {
|
||||
runtimeSharingPatch(t, rtID, actor, map[string]any{"shared_with": []string{testUserID}}).Want(http.StatusForbidden)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, rtID) })
|
||||
testutil.Call(t, testHandler.CreateAgent, newRequestAs(memberID, http.MethodPost, "/api/agents", map[string]any{
|
||||
"name": "Shared runtime recipient", "runtime_id": rtID,
|
||||
})).Want(http.StatusCreated)
|
||||
testutil.Call(t, testHandler.CreateAgent, newRequestAs(testUserID, http.MethodPost, "/api/agents", map[string]any{
|
||||
"name": "Unselected runtime admin", "runtime_id": rtID,
|
||||
})).Want(http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestRuntimeSharingRejectsInvalidRecipients(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Fatal("database-backed fixture required")
|
||||
}
|
||||
rtID, ownerID, _ := runtimeVisibilityFixture(t)
|
||||
outsider := dbfx.Insert(t, "user", testutil.Cols{"name": "Outside workspace", "email": "[email protected]"})
|
||||
for _, recipients := range [][]string{{"bad-uuid"}, {outsider}} {
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": recipients, "custom_name": "must not save"}).Want(http.StatusBadRequest)
|
||||
}
|
||||
req := newRequestAs(outsider, http.MethodPatch, "/api/runtimes/"+rtID, map[string]any{"shared_with": []string{outsider}})
|
||||
testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound)
|
||||
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
||||
if err != nil || len(rt.SharedWith) != 0 || rt.CustomName.Valid {
|
||||
t.Fatalf("invalid input partially saved: %+v, %v", rt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSharingRevokesOnlyRemovedMembers(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Fatal("database-backed fixture required")
|
||||
}
|
||||
for _, visibility := range []string{"public", "private"} {
|
||||
t.Run(visibility, func(t *testing.T) {
|
||||
rtID, ownerID, removedID := runtimeVisibilityFixture(t)
|
||||
dbfx.Exec(t, `UPDATE agent_runtime SET visibility = $2, shared_with = ARRAY[$3::uuid, $4::uuid] WHERE id = $1`, rtID, visibility, removedID, testUserID)
|
||||
removedAgent := createRuntimePrivacyAgent(t, rtID, removedID, "Removed member agent")
|
||||
keptAgent := createRuntimePrivacyAgent(t, rtID, testUserID, "Kept member agent")
|
||||
ownAgent := createRuntimePrivacyAgent(t, rtID, ownerID, "Runtime owner agent")
|
||||
removedTask := dbfx.Task(t, removedAgent, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
||||
keptTask := dbfx.Task(t, keptAgent, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
||||
var plan struct {
|
||||
Agents []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"nonowner_agents"`
|
||||
Tasks []string `json:"active_task_ids"`
|
||||
}
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private", "shared_with": []string{testUserID}}).Want(http.StatusConflict).JSON(&plan)
|
||||
if len(plan.Agents) != 1 || plan.Agents[0].ID != removedAgent || len(plan.Tasks) != 1 || plan.Tasks[0] != removedTask {
|
||||
t.Fatalf("wrong revocation plan: %+v", plan)
|
||||
}
|
||||
runtimePrivacyRequest(t, rtID, ownerID, map[string]any{
|
||||
"shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{},
|
||||
}).Want(http.StatusConflict)
|
||||
runtimePrivacyRequest(t, rtID, ownerID, map[string]any{
|
||||
"shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{removedTask},
|
||||
}).Want(http.StatusOK)
|
||||
for _, agentID := range []string{keptAgent, ownAgent} {
|
||||
a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID))
|
||||
if err != nil || a.RuntimeID != mustTestUUID(t, rtID) {
|
||||
t.Fatalf("kept agent lost binding: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct{ id, status string }{{removedTask, "cancelled"}, {keptTask, "running"}} {
|
||||
task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, tc.id))
|
||||
if err != nil || task.Status != tc.status {
|
||||
t.Fatalf("task status=%s, want %s, err=%v", task.Status, tc.status, err)
|
||||
}
|
||||
}
|
||||
a, _ := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, removedAgent))
|
||||
if a.RuntimeID.Valid {
|
||||
t.Fatal("removed agent remains bound")
|
||||
}
|
||||
req := newRequestAs(removedID, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil)
|
||||
testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSharingOldClientPrivateClearsGrants(t *testing.T) {
|
||||
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
||||
var rt AgentRuntimeResponse
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private"}).Want(http.StatusOK).JSON(&rt)
|
||||
if len(rt.SharedWith) != 0 {
|
||||
t.Fatal("explicit old-client private request must clear grants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSharingMemberRemovalClearsGrant(t *testing.T) {
|
||||
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
||||
agentID := createRuntimePrivacyAgent(t, rtID, memberID, "Departing member agent")
|
||||
taskID := dbfx.Task(t, agentID, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
||||
|
||||
member, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
||||
WorkspaceID: mustTestUUID(t, testWorkspaceID), UserID: mustTestUUID(t, memberID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := newRequest(http.MethodDelete, "/api/workspaces/"+testWorkspaceID+"/members/"+uuidToString(member.ID), nil)
|
||||
testutil.Call(t, testHandler.DeleteMember, testutil.WithURLParams(req, "id", testWorkspaceID, "memberId", uuidToString(member.ID))).Want(http.StatusNoContent)
|
||||
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
||||
if err != nil || len(rt.SharedWith) != 0 {
|
||||
t.Fatalf("departed member retained grant: %+v %v", rt.SharedWith, err)
|
||||
}
|
||||
task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, taskID))
|
||||
if err != nil || task.Status != "cancelled" {
|
||||
t.Fatalf("departed member's run survived: %+v %v", task, err)
|
||||
}
|
||||
a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID))
|
||||
if err != nil || a.RuntimeID.Valid {
|
||||
t.Fatalf("departed member's agent remains bound: %+v %v", a, err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestRuntimeSharingClaimAndReclaim(t *testing.T) {
|
||||
for _, reclaim := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint("reclaim=", reclaim), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
memberID := dbfx.User(t, "Shared claim member", "shared-claim-"+uuid.NewString()+"@example.com")
|
||||
dbfx.Member(t, testWorkspaceID, memberID, "member")
|
||||
runtimeID := createClaimReclaimRuntime(t, ctx, "Shared claim runtime")
|
||||
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Shared claim agent")
|
||||
dbfx.Exec(t, `UPDATE agent SET owner_id = $1 WHERE id = $2`, memberID, agentID)
|
||||
runtimeSharingPatch(t, runtimeID, testUserID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
||||
taskID := seedQueuedIssueTask(t, ctx, agentID, runtimeID, issueID)
|
||||
if reclaim {
|
||||
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() - interval '10 minutes', prepare_lease_expires_at = NULL WHERE id = $1`, taskID)
|
||||
}
|
||||
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil)
|
||||
var body struct {
|
||||
Task *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"task"`
|
||||
}
|
||||
testutil.Call(t, testHandler.ClaimTaskByRuntime, withURLParam(req, "runtimeId", runtimeID)).Want(http.StatusOK).JSON(&body)
|
||||
if body.Task == nil || body.Task.ID != taskID {
|
||||
t.Fatalf("shared member task not delivered: %+v", body.Task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSharingMemberRemovalContention(t *testing.T) {
|
||||
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
||||
tx, err := testPool.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err := tx.Exec(context.Background(), `SELECT id FROM member WHERE workspace_id = $1 AND user_id = $2 FOR UPDATE`, testWorkspaceID, memberID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusConflict)
|
||||
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
||||
if err != nil || len(rt.SharedWith) != 0 {
|
||||
t.Fatal("busy grant partially persisted")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -65,6 +66,12 @@ func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID
|
||||
return empty, err
|
||||
}
|
||||
|
||||
if _, err := qtx.LockRuntimeShareMemberRemoval(ctx, db.LockRuntimeShareMemberRemovalParams{
|
||||
WorkspaceID: workspaceID, UserID: userID,
|
||||
}); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
runtimes, err := qtx.ListAgentRuntimesByOwner(ctx, db.ListAgentRuntimesByOwnerParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: userID,
|
||||
@@ -220,6 +227,36 @@ func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID
|
||||
return empty, err
|
||||
}
|
||||
}
|
||||
sharedRuntimes, err := qtx.ListSharedAgentRuntimesForUpdate(ctx, db.ListSharedAgentRuntimesForUpdateParams{
|
||||
WorkspaceID: workspaceID, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
result.SharedRuntimeCount = len(sharedRuntimes)
|
||||
for _, rt := range sharedRuntimes {
|
||||
rt.SharedWith = slices.DeleteFunc(rt.SharedWith, func(id pgtype.UUID) bool { return id == userID })
|
||||
if rt.Visibility == "public" {
|
||||
if _, err := qtx.UpdateAgentRuntimeVisibility(ctx, db.UpdateAgentRuntimeVisibilityParams{
|
||||
ID: rt.ID, Visibility: rt.Visibility, SharedWith: rt.SharedWith,
|
||||
}); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := lockRuntimeRevocationPlan(ctx, qtx, rt); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
_, teardown, readyChats, err := applyRuntimeAccessRevocation(ctx, qtx, rt)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
result.SharedTeardown.UnboundAgents = append(result.SharedTeardown.UnboundAgents, teardown.UnboundAgents...)
|
||||
result.SharedTeardown.CancelledTasks = append(result.SharedTeardown.CancelledTasks, teardown.CancelledTasks...)
|
||||
result.SharedTeardown.PausedAutopilots = append(result.SharedTeardown.PausedAutopilots, teardown.PausedAutopilots...)
|
||||
result.ReadyChats = append(result.ReadyChats, readyChats...)
|
||||
}
|
||||
|
||||
if err := qtx.DeleteMember(ctx, memberID); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
@@ -241,10 +278,13 @@ type revocationResult struct {
|
||||
CancelledTasks []db.AgentTaskQueue
|
||||
OfflineRuntimeIDs []db.ForceOfflineRuntimesByIDsRow
|
||||
RevokedTokenHashes []string
|
||||
SharedRuntimeCount int
|
||||
SharedTeardown service.RuntimeTeardownResult
|
||||
ReadyChats []pgtype.UUID
|
||||
}
|
||||
|
||||
func (r revocationResult) isEmpty() bool {
|
||||
return len(r.Runtimes) == 0
|
||||
return len(r.Runtimes) == 0 && r.SharedRuntimeCount == 0
|
||||
}
|
||||
|
||||
// publishRevocation runs all post-commit side effects: invalidate daemon token
|
||||
@@ -277,12 +317,19 @@ func (h *Handler) publishRevocation(ctx context.Context, result revocationResult
|
||||
})
|
||||
}
|
||||
|
||||
h.PublishRuntimeTeardown(ctx, result.SharedTeardown, workspaceIDStr, actorType, actorIDStr, "revoke", false)
|
||||
if h.TaskService != nil {
|
||||
for _, taskID := range result.ReadyChats {
|
||||
h.TaskService.FinalizeDeferredCancelledChat(ctx, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// Tell connected clients to refresh the runtime list. We piggyback on
|
||||
// EventDaemonRegister with a "revoke" action — same channel the runtime
|
||||
// delete handler uses — so the frontend invalidates its cached list
|
||||
// without us having to introduce a new event type the desktop app would
|
||||
// need a build to learn about.
|
||||
if len(result.OfflineRuntimeIDs) > 0 {
|
||||
if len(result.OfflineRuntimeIDs) > 0 || result.SharedRuntimeCount > 0 {
|
||||
h.publish(protocol.EventDaemonRegister, workspaceIDStr, actorType, actorIDStr, map[string]any{
|
||||
"action": "revoke",
|
||||
})
|
||||
|
||||
@@ -154,11 +154,11 @@ func AgentReadiness(ctx context.Context, lookup RuntimeLookup, agent db.Agent) (
|
||||
// runtimeVerdict combines runtime health with the ownership binding that
|
||||
// determines whether this agent can execute there.
|
||||
func runtimeVerdict(rt db.AgentRuntime, agent db.Agent) AgentVerdict {
|
||||
if rt.Visibility == "private" && rt.OwnerID.Valid && (!agent.OwnerID.Valid || agent.OwnerID != rt.OwnerID) {
|
||||
if rt.Visibility == "private" && rt.OwnerID.Valid && !RuntimeAllowsUser(rt, agent.OwnerID) {
|
||||
return AgentVerdict{
|
||||
Availability: AgentBlocked,
|
||||
Reason: dispatch.ReasonRuntimeAccessDenied,
|
||||
Detail: "agent owner does not match private runtime owner",
|
||||
Detail: "agent owner does not have access to the private runtime",
|
||||
}
|
||||
}
|
||||
if rt.Status == "online" {
|
||||
@@ -269,7 +269,7 @@ func RuntimeUnusableNotice(agentName string, verdict AgentVerdict) string {
|
||||
}
|
||||
if verdict.Reason == dispatch.ReasonRuntimeAccessDenied {
|
||||
return fmt.Sprintf(
|
||||
"%s cannot run on this private runtime, so this trigger was not queued. Make the runtime's machine public, or rebind/copy the agent to a runtime its owner can use.",
|
||||
"%s cannot run on this private runtime, so this trigger was not queued. Ask its owner to share the runtime with the agent owner or make it public, or rebind/copy the agent to a runtime its owner can use.",
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// RuntimeAllowsUser evaluates machine-owner consent after workspace membership
|
||||
// has been checked by the caller. Sharing never grants machine administration.
|
||||
func RuntimeAllowsUser(rt db.AgentRuntime, userID pgtype.UUID) bool {
|
||||
return rt.OwnerID.Valid && userID.Valid && (rt.OwnerID == userID ||
|
||||
rt.Visibility == "public" || (rt.Visibility == "private" && slices.Contains(rt.SharedWith, userID)))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimeSharedMemberReadiness(t *testing.T) {
|
||||
owner := util.MustParseUUID("11111111-1111-4111-8111-111111111111")
|
||||
member := util.MustParseUUID("22222222-2222-4222-8222-222222222222")
|
||||
other := util.MustParseUUID("33333333-3333-4333-8333-333333333333")
|
||||
rt := db.AgentRuntime{OwnerID: owner, Visibility: "private", SharedWith: []pgtype.UUID{member}, Status: "online"}
|
||||
for _, tc := range []struct {
|
||||
id pgtype.UUID
|
||||
want bool
|
||||
}{{owner, true}, {member, true}, {other, false}, {pgtype.UUID{}, false}} {
|
||||
if got := RuntimeAllowsUser(rt, tc.id); got != tc.want {
|
||||
t.Fatalf("access=%v want %v", got, tc.want)
|
||||
}
|
||||
if got := runtimeVerdict(rt, db.Agent{OwnerID: tc.id}).Ready(); got != tc.want {
|
||||
t.Fatalf("ready=%v want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
rt.SharedWith = nil
|
||||
if runtimeVerdict(rt, db.Agent{OwnerID: member}).Ready() {
|
||||
t.Fatal("removed member remains ready")
|
||||
}
|
||||
rt.SharedWith = []pgtype.UUID{member}
|
||||
rt.OwnerID = pgtype.UUID{}
|
||||
if RuntimeAllowsUser(rt, member) {
|
||||
t.Fatal("ownerless runtime grants access")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent_runtime DROP COLUMN IF EXISTS shared_with;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Explicit workspace user grants keep visibility private for older servers.
|
||||
ALTER TABLE agent_runtime ADD COLUMN shared_with uuid[] NOT NULL DEFAULT '{}';
|
||||
@@ -5664,20 +5664,21 @@ const listNonOwnerAgentsByRuntime = `-- name: ListNonOwnerAgentsByRuntime :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters FROM agent
|
||||
WHERE runtime_id = $1
|
||||
AND owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(owner_id = ANY($3::uuid[]), false)
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
type ListNonOwnerAgentsByRuntimeParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Returns every agent bound to a runtime whose owner differs from its owner,
|
||||
// including hidden Builder execution carriers. This is the dependency plan shown before a public
|
||||
// runtime is made private: owner-owned agents keep their binding, while every
|
||||
// teammate-owned agent loses machine access.
|
||||
// Returns bound agents whose owners lose access under the desired shared_with
|
||||
// list, including hidden Builder execution carriers. Runtime-owner agents keep
|
||||
// their binding.
|
||||
func (q *Queries) ListNonOwnerAgentsByRuntime(ctx context.Context, arg ListNonOwnerAgentsByRuntimeParams) ([]Agent, error) {
|
||||
rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntime, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5730,19 +5731,21 @@ const listNonOwnerAgentsByRuntimeForUpdate = `-- name: ListNonOwnerAgentsByRunti
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters FROM agent
|
||||
WHERE runtime_id = $1
|
||||
AND owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(owner_id = ANY($3::uuid[]), false)
|
||||
ORDER BY id
|
||||
FOR UPDATE NOWAIT
|
||||
`
|
||||
|
||||
type ListNonOwnerAgentsByRuntimeForUpdateParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Runtime access-change locking serializes new bindings. NOWAIT avoids waiting
|
||||
// behind a writer that already holds an agent and needs the runtime row.
|
||||
func (q *Queries) ListNonOwnerAgentsByRuntimeForUpdate(ctx context.Context, arg ListNonOwnerAgentsByRuntimeForUpdateParams) ([]Agent, error) {
|
||||
rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, listNonOwnerAgentsByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -7761,6 +7764,7 @@ WHERE id = (
|
||||
r.owner_id IS NULL
|
||||
OR a.owner_id IS NULL
|
||||
OR r.owner_id = a.owner_id
|
||||
OR a.owner_id = ANY(r.shared_with)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -7887,6 +7891,7 @@ WHERE id IN (
|
||||
r.owner_id IS NULL
|
||||
OR a.owner_id IS NULL
|
||||
OR r.owner_id = a.owner_id
|
||||
OR a.owner_id = ANY(r.shared_with)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ type AgentRuntime struct {
|
||||
Visibility string `json:"visibility"`
|
||||
ProfileID pgtype.UUID `json:"profile_id"`
|
||||
CustomName pgtype.Text `json:"custom_name"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
type AgentSkill struct {
|
||||
|
||||
@@ -140,14 +140,16 @@ FROM agent
|
||||
WHERE task.agent_id = agent.id
|
||||
AND task.runtime_id = $2
|
||||
AND agent.owner_id IS DISTINCT FROM $3
|
||||
AND NOT COALESCE(agent.owner_id = ANY($4::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
RETURNING task.id, task.agent_id, task.issue_id, task.status, task.priority, task.dispatched_at, task.started_at, task.completed_at, task.result, task.error, task.created_at, task.context, task.runtime_id, task.session_id, task.work_dir, task.trigger_comment_id, task.chat_session_id, task.autopilot_run_id, task.attempt, task.max_attempts, task.parent_task_id, task.failure_reason, task.trigger_summary, task.force_fresh_session, task.is_leader_task, task.wait_reason, task.initiator_user_id, task.handoff_note, task.prepare_lease_expires_at, task.squad_id, task.runtime_mcp_overlay, task.escalation_for_task_id, task.fire_at, task.originator_user_id, task.runtime_connected_apps, task.coalesced_comment_ids, task.delivered_comment_ids, task.chat_input_task_id, task.chat_finalize_deferred_at, task.originator_source, task.delegated_from_task_id, task.retry_of_task_id, task.rerun_of_task_id, task.rule_version_id, task.trigger_evidence_kind, task.trigger_evidence_ref_id, task.accountable_user_id, task.session_rollout_missing, task.retired_session_id, task.quick_actions_disabled, task.regenerate_quick_actions_for, task.branch_name, task.durable_work_dir, task.channel_context_revision, task.comment_thread_id, task.cancelled_by_type, task.cancelled_by_id, task.cancelled_by_name, task.issue_snapshot, task.workflow_step
|
||||
`
|
||||
|
||||
type CancelNonOwnerTasksByRuntimeParams struct {
|
||||
FailureReason string `json:"failure_reason"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Server-side execution revocation for a public runtime made private. The
|
||||
@@ -156,7 +158,12 @@ type CancelNonOwnerTasksByRuntimeParams struct {
|
||||
// cancellation polling on an online daemon observes the state change; an
|
||||
// offline daemon cannot run new work and will observe it after reconnecting.
|
||||
func (q *Queries) CancelNonOwnerTasksByRuntime(ctx context.Context, arg CancelNonOwnerTasksByRuntimeParams) ([]AgentTaskQueue, error) {
|
||||
rows, err := q.db.Query(ctx, cancelNonOwnerTasksByRuntime, arg.FailureReason, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, cancelNonOwnerTasksByRuntime,
|
||||
arg.FailureReason,
|
||||
arg.RuntimeID,
|
||||
arg.RuntimeOwnerID,
|
||||
arg.SharedWith,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +432,7 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context, arg FailTasks
|
||||
}
|
||||
|
||||
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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
AND provider = $2
|
||||
AND LOWER(daemon_id) = LOWER($3)
|
||||
@@ -479,6 +486,7 @@ func (q *Queries) FindLegacyRuntimesByDaemonID(ctx context.Context, arg FindLega
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -538,7 +546,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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -563,12 +571,13 @@ func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRun
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -598,6 +607,7 @@ func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentR
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -652,7 +662,7 @@ func (q *Queries) GetAgentRuntimeHeartbeatLeases(ctx context.Context, ids []pgty
|
||||
}
|
||||
|
||||
const getAgentRuntimes = `-- name: GetAgentRuntimes :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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
@@ -688,6 +698,7 @@ func (q *Queries) GetAgentRuntimes(ctx context.Context, ids []pgtype.UUID) ([]Ag
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -758,7 +769,7 @@ func (q *Queries) ListAgentRuntimeIDsByWorkspace(ctx context.Context, workspaceI
|
||||
}
|
||||
|
||||
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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -790,6 +801,7 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -802,7 +814,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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE workspace_id = $1 AND owner_id = $2
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -839,6 +851,7 @@ func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRun
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -896,21 +909,21 @@ FROM agent_task_queue AS task
|
||||
JOIN agent ON agent.id = task.agent_id
|
||||
WHERE task.runtime_id = $1
|
||||
AND agent.owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
ORDER BY task.id
|
||||
`
|
||||
|
||||
type ListNonOwnerActiveTasksByRuntimeParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Lists live tasks targeting a public runtime but belonging to somebody other
|
||||
// than its owner, including hidden Builder execution carriers. It forms part of the public-to-private
|
||||
// confirmation plan so the owner explicitly approves cancelling work that
|
||||
// could otherwise keep running after machine access is revoked.
|
||||
// Lists live tasks whose agent owner will lose runtime access, including
|
||||
// hidden Builder execution carriers. shared_with is the desired recipient list.
|
||||
func (q *Queries) ListNonOwnerActiveTasksByRuntime(ctx context.Context, arg ListNonOwnerActiveTasksByRuntimeParams) ([]AgentTaskQueue, error) {
|
||||
rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntime, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -996,20 +1009,22 @@ FROM agent_task_queue AS task
|
||||
JOIN agent ON agent.id = task.agent_id
|
||||
WHERE task.runtime_id = $1
|
||||
AND agent.owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
ORDER BY task.id
|
||||
FOR UPDATE OF task
|
||||
`
|
||||
|
||||
type ListNonOwnerActiveTasksByRuntimeForUpdateParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Acquired after the affected agent and chat-session locks. Bindings serialize
|
||||
// on LockAgentRuntimeForAccessChange; claim/delivery rechecks authorization.
|
||||
func (q *Queries) ListNonOwnerActiveTasksByRuntimeForUpdate(ctx context.Context, arg ListNonOwnerActiveTasksByRuntimeForUpdateParams) ([]AgentTaskQueue, error) {
|
||||
rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, listNonOwnerActiveTasksByRuntimeForUpdate, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1127,6 +1142,57 @@ func (q *Queries) ListRuntimeOwnersByDaemon(ctx context.Context, arg ListRuntime
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSharedAgentRuntimesForUpdate = `-- name: ListSharedAgentRuntimesForUpdate :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, profile_id, custom_name, shared_with FROM agent_runtime
|
||||
WHERE workspace_id = $1 AND $2::uuid = ANY(shared_with)
|
||||
ORDER BY id
|
||||
FOR NO KEY UPDATE
|
||||
`
|
||||
|
||||
type ListSharedAgentRuntimesForUpdateParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSharedAgentRuntimesForUpdate(ctx context.Context, arg ListSharedAgentRuntimesForUpdateParams) ([]AgentRuntime, error) {
|
||||
rows, err := q.db.Query(ctx, listSharedAgentRuntimesForUpdate, arg.WorkspaceID, arg.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AgentRuntime{}
|
||||
for rows.Next() {
|
||||
var i AgentRuntime
|
||||
if err := rows.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.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listStaleOfflineRuntimeGCCandidates = `-- name: ListStaleOfflineRuntimeGCCandidates :many
|
||||
SELECT id FROM agent_runtime
|
||||
WHERE status = 'offline'
|
||||
@@ -1179,9 +1245,9 @@ func (q *Queries) ListStaleOfflineRuntimeGCCandidates(ctx context.Context, arg L
|
||||
}
|
||||
|
||||
const listVisibleAgentRuntimes = `-- name: ListVisibleAgentRuntimes :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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
AND (owner_id = $2 OR visibility = 'public')
|
||||
AND (owner_id = $2 OR visibility = 'public' OR (visibility = 'private' AND $2 = ANY(shared_with)))
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
@@ -1192,7 +1258,7 @@ type ListVisibleAgentRuntimesParams struct {
|
||||
|
||||
// A private runtime is another member's machine and must not leak into their
|
||||
// runtime list. The owner can always see their own runtime; everyone else
|
||||
// sees only runtimes the owner has explicitly shared with the workspace.
|
||||
// sees only runtimes shared with them or with the entire workspace.
|
||||
func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleAgentRuntimesParams) ([]AgentRuntime, error) {
|
||||
rows, err := q.db.Query(ctx, listVisibleAgentRuntimes, arg.WorkspaceID, arg.OwnerID)
|
||||
if err != nil {
|
||||
@@ -1220,6 +1286,7 @@ func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleA
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1232,7 +1299,7 @@ func (q *Queries) ListVisibleAgentRuntimes(ctx context.Context, arg ListVisibleA
|
||||
}
|
||||
|
||||
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, profile_id, custom_name 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, custom_name, shared_with FROM agent_runtime
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`
|
||||
@@ -1271,12 +1338,13 @@ func (q *Queries) LockAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRu
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const lockAgentRuntimeForAccessChange = `-- name: LockAgentRuntimeForAccessChange :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, profile_id, custom_name FROM agent_runtime WHERE id = $1 FOR NO KEY UPDATE
|
||||
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, custom_name, shared_with FROM agent_runtime WHERE id = $1 FOR NO KEY UPDATE
|
||||
`
|
||||
|
||||
// Binding and visibility writers share this lock and recheck access under it.
|
||||
@@ -1303,6 +1371,7 @@ func (q *Queries) LockAgentRuntimeForAccessChange(ctx context.Context, id pgtype
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1314,6 +1383,7 @@ WHERE EXISTS (
|
||||
WHERE task.chat_session_id = cs.id
|
||||
AND task.runtime_id = $1
|
||||
AND agent.owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(agent.owner_id = ANY($3::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
)
|
||||
ORDER BY cs.id
|
||||
@@ -1321,15 +1391,16 @@ FOR UPDATE OF cs NOWAIT
|
||||
`
|
||||
|
||||
type LockRuntimeRevocationChatSessionsParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// Revocation already holds affected agents. Never wait in the reverse of the
|
||||
// normal session -> agent/task order: a concurrent chat writer yields a 409
|
||||
// and the entire revocation rolls back before changing anything.
|
||||
func (q *Queries) LockRuntimeRevocationChatSessions(ctx context.Context, arg LockRuntimeRevocationChatSessionsParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, lockRuntimeRevocationChatSessions, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, lockRuntimeRevocationChatSessions, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1348,6 +1419,60 @@ func (q *Queries) LockRuntimeRevocationChatSessions(ctx context.Context, arg Loc
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const lockRuntimeShareMemberRemoval = `-- name: LockRuntimeShareMemberRemoval :one
|
||||
SELECT id FROM member
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
FOR UPDATE
|
||||
`
|
||||
|
||||
type LockRuntimeShareMemberRemovalParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
// Prevent new grants until membership deletion commits. Grant writers use
|
||||
// NOWAIT on this row, so they never wait while holding a runtime we need.
|
||||
func (q *Queries) LockRuntimeShareMemberRemoval(ctx context.Context, arg LockRuntimeShareMemberRemovalParams) (pgtype.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, lockRuntimeShareMemberRemoval, arg.WorkspaceID, arg.UserID)
|
||||
var id pgtype.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const lockRuntimeShareMembers = `-- name: LockRuntimeShareMembers :many
|
||||
SELECT user_id FROM member
|
||||
WHERE workspace_id = $1 AND user_id = ANY($2::uuid[])
|
||||
ORDER BY user_id
|
||||
FOR KEY SHARE NOWAIT
|
||||
`
|
||||
|
||||
type LockRuntimeShareMembersParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserIds []pgtype.UUID `json:"user_ids"`
|
||||
}
|
||||
|
||||
// Keep grant validation and member removal serialized until commit. NOWAIT
|
||||
// avoids the member-removal path's reverse member/runtime lock order.
|
||||
func (q *Queries) LockRuntimeShareMembers(ctx context.Context, arg LockRuntimeShareMembersParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, lockRuntimeShareMembers, arg.WorkspaceID, arg.UserIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []pgtype.UUID{}
|
||||
for rows.Next() {
|
||||
var user_id pgtype.UUID
|
||||
if err := rows.Scan(&user_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, user_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const lockRuntimesForMerge = `-- name: LockRuntimesForMerge :many
|
||||
SELECT id, workspace_id, owner_id FROM agent_runtime
|
||||
WHERE id = ANY($1::uuid[])
|
||||
@@ -1422,7 +1547,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, profile_id, custom_name
|
||||
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, custom_name, shared_with
|
||||
`
|
||||
|
||||
// Used on the offline→online transition (and on first heartbeat after
|
||||
@@ -1449,6 +1574,7 @@ func (q *Queries) MarkAgentRuntimeOnline(ctx context.Context, id pgtype.UUID) (A
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1768,19 +1894,20 @@ UPDATE agent
|
||||
SET runtime_id = NULL, updated_at = now()
|
||||
WHERE runtime_id = $1
|
||||
AND owner_id IS DISTINCT FROM $2
|
||||
AND NOT COALESCE(owner_id = ANY($3::uuid[]), false)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist, permission_mode, kind, system_key, disabled_runtime_skills, service_tier, conversation_starters
|
||||
`
|
||||
|
||||
type UnbindNonOwnerAgentsFromRuntimeParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
RuntimeOwnerID pgtype.UUID `json:"runtime_owner_id"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
}
|
||||
|
||||
// The companion to CancelNonOwnerTasksByRuntime for a public-to-private
|
||||
// transition. Only teammates lose the binding; the runtime owner's agents
|
||||
// remain bound and usable after the machine becomes private again.
|
||||
// Companion to CancelNonOwnerTasksByRuntime: preserve agents belonging to
|
||||
// the runtime owner or to recipients who retain access.
|
||||
func (q *Queries) UnbindNonOwnerAgentsFromRuntime(ctx context.Context, arg UnbindNonOwnerAgentsFromRuntimeParams) ([]Agent, error) {
|
||||
rows, err := q.db.Query(ctx, unbindNonOwnerAgentsFromRuntime, arg.RuntimeID, arg.RuntimeOwnerID)
|
||||
rows, err := q.db.Query(ctx, unbindNonOwnerAgentsFromRuntime, arg.RuntimeID, arg.RuntimeOwnerID, arg.SharedWith)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1922,7 +2049,7 @@ const updateAgentRuntimeCustomName = `-- name: UpdateAgentRuntimeCustomName :one
|
||||
UPDATE agent_runtime
|
||||
SET custom_name = $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, profile_id, custom_name
|
||||
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, custom_name, shared_with
|
||||
`
|
||||
|
||||
type UpdateAgentRuntimeCustomNameParams struct {
|
||||
@@ -1956,6 +2083,7 @@ func (q *Queries) UpdateAgentRuntimeCustomName(ctx context.Context, arg UpdateAg
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1966,7 +2094,7 @@ SET custom_name = $1, updated_at = now()
|
||||
WHERE workspace_id = $2
|
||||
AND daemon_id = $3
|
||||
AND ($4::uuid IS NULL OR owner_id = $4)
|
||||
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, custom_name
|
||||
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, custom_name, shared_with
|
||||
`
|
||||
|
||||
type UpdateAgentRuntimeCustomNameByDaemonParams struct {
|
||||
@@ -2014,6 +2142,7 @@ func (q *Queries) UpdateAgentRuntimeCustomNameByDaemon(ctx context.Context, arg
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2027,23 +2156,24 @@ func (q *Queries) UpdateAgentRuntimeCustomNameByDaemon(ctx context.Context, arg
|
||||
|
||||
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, profile_id, custom_name
|
||||
SET visibility = $1, shared_with = $2::uuid[], updated_at = now()
|
||||
WHERE id = $3
|
||||
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, custom_name, shared_with
|
||||
`
|
||||
|
||||
type UpdateAgentRuntimeVisibilityParams struct {
|
||||
Visibility string `json:"visibility"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Visibility string `json:"visibility"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
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'
|
||||
// Replaces the runtime's access policy: private admits owner plus shared_with;
|
||||
// public admits any workspace member. Default for new rows is private
|
||||
// (see migration 083). Gated at the handler layer to the runtime owner only:
|
||||
// visibility is consent to use that person's machine, not a workspace-governance
|
||||
// setting.
|
||||
func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAgentRuntimeVisibilityParams) (AgentRuntime, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.ID)
|
||||
row := q.db.QueryRow(ctx, updateAgentRuntimeVisibility, arg.Visibility, arg.SharedWith, arg.ID)
|
||||
var i AgentRuntime
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -2063,6 +2193,7 @@ func (q *Queries) UpdateAgentRuntimeVisibility(ctx context.Context, arg UpdateAg
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2095,7 +2226,7 @@ DO UPDATE SET
|
||||
updated_at = now()
|
||||
WHERE $10::uuid IS NULL
|
||||
OR agent_runtime.owner_id = $10::uuid
|
||||
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, custom_name, (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, custom_name, shared_with, (xmax = 0) AS inserted
|
||||
`
|
||||
|
||||
type UpsertAgentRuntimeParams struct {
|
||||
@@ -2129,6 +2260,7 @@ type UpsertAgentRuntimeRow struct {
|
||||
Visibility string `json:"visibility"`
|
||||
ProfileID pgtype.UUID `json:"profile_id"`
|
||||
CustomName pgtype.Text `json:"custom_name"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
Inserted bool `json:"inserted"`
|
||||
}
|
||||
|
||||
@@ -2179,6 +2311,7 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
&i.Inserted,
|
||||
)
|
||||
return i, err
|
||||
@@ -2214,7 +2347,7 @@ DO UPDATE SET
|
||||
updated_at = now()
|
||||
WHERE $11::uuid IS NULL
|
||||
OR agent_runtime.owner_id = $11::uuid
|
||||
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, custom_name, (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, custom_name, shared_with, (xmax = 0) AS inserted
|
||||
`
|
||||
|
||||
type UpsertAgentRuntimeWithProfileParams struct {
|
||||
@@ -2249,6 +2382,7 @@ type UpsertAgentRuntimeWithProfileRow struct {
|
||||
Visibility string `json:"visibility"`
|
||||
ProfileID pgtype.UUID `json:"profile_id"`
|
||||
CustomName pgtype.Text `json:"custom_name"`
|
||||
SharedWith []pgtype.UUID `json:"shared_with"`
|
||||
Inserted bool `json:"inserted"`
|
||||
}
|
||||
|
||||
@@ -2293,6 +2427,7 @@ func (q *Queries) UpsertAgentRuntimeWithProfile(ctx context.Context, arg UpsertA
|
||||
&i.Visibility,
|
||||
&i.ProfileID,
|
||||
&i.CustomName,
|
||||
&i.SharedWith,
|
||||
&i.Inserted,
|
||||
)
|
||||
return i, err
|
||||
|
||||
@@ -281,13 +281,13 @@ ORDER BY id
|
||||
FOR UPDATE;
|
||||
|
||||
-- name: ListNonOwnerAgentsByRuntime :many
|
||||
-- Returns every agent bound to a runtime whose owner differs from its owner,
|
||||
-- including hidden Builder execution carriers. This is the dependency plan shown before a public
|
||||
-- runtime is made private: owner-owned agents keep their binding, while every
|
||||
-- teammate-owned agent loses machine access.
|
||||
-- Returns bound agents whose owners lose access under the desired shared_with
|
||||
-- list, including hidden Builder execution carriers. Runtime-owner agents keep
|
||||
-- their binding.
|
||||
SELECT * FROM agent
|
||||
WHERE runtime_id = @runtime_id
|
||||
AND owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false)
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: ListNonOwnerAgentsByRuntimeForUpdate :many
|
||||
@@ -296,6 +296,7 @@ ORDER BY name ASC;
|
||||
SELECT * FROM agent
|
||||
WHERE runtime_id = @runtime_id
|
||||
AND owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false)
|
||||
ORDER BY id
|
||||
FOR UPDATE NOWAIT;
|
||||
|
||||
@@ -879,6 +880,7 @@ WHERE id = (
|
||||
r.owner_id IS NULL
|
||||
OR a.owner_id IS NULL
|
||||
OR r.owner_id = a.owner_id
|
||||
OR a.owner_id = ANY(r.shared_with)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -926,6 +928,7 @@ WHERE id IN (
|
||||
r.owner_id IS NULL
|
||||
OR a.owner_id IS NULL
|
||||
OR r.owner_id = a.owner_id
|
||||
OR a.owner_id = ANY(r.shared_with)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,10 +11,10 @@ ORDER BY id ASC;
|
||||
-- name: ListVisibleAgentRuntimes :many
|
||||
-- A private runtime is another member's machine and must not leak into their
|
||||
-- runtime list. The owner can always see their own runtime; everyone else
|
||||
-- sees only runtimes the owner has explicitly shared with the workspace.
|
||||
-- sees only runtimes shared with them or with the entire workspace.
|
||||
SELECT * FROM agent_runtime
|
||||
WHERE workspace_id = $1
|
||||
AND (owner_id = $2 OR visibility = 'public')
|
||||
AND (owner_id = $2 OR visibility = 'public' OR (visibility = 'private' AND $2 = ANY(shared_with)))
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetAgentRuntime :one
|
||||
@@ -149,13 +149,13 @@ WHERE sqlc.narg('required_owner_id')::uuid IS NULL
|
||||
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'
|
||||
-- Replaces the runtime's access policy: private admits owner plus shared_with;
|
||||
-- public admits any workspace member. Default for new rows is private
|
||||
-- (see migration 083). Gated at the handler layer to the runtime owner only:
|
||||
-- visibility is consent to use that person's machine, not a workspace-governance
|
||||
-- setting.
|
||||
UPDATE agent_runtime
|
||||
SET visibility = @visibility, updated_at = now()
|
||||
SET visibility = @visibility, shared_with = @shared_with::uuid[], updated_at = now()
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
@@ -375,15 +375,14 @@ WHERE (runtime_id = ANY(@runtime_ids::uuid[]) OR agent_id = ANY(@agent_ids::uuid
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListNonOwnerActiveTasksByRuntime :many
|
||||
-- Lists live tasks targeting a public runtime but belonging to somebody other
|
||||
-- than its owner, including hidden Builder execution carriers. It forms part of the public-to-private
|
||||
-- confirmation plan so the owner explicitly approves cancelling work that
|
||||
-- could otherwise keep running after machine access is revoked.
|
||||
-- Lists live tasks whose agent owner will lose runtime access, including
|
||||
-- hidden Builder execution carriers. shared_with is the desired recipient list.
|
||||
SELECT task.*
|
||||
FROM agent_task_queue AS task
|
||||
JOIN agent ON agent.id = task.agent_id
|
||||
WHERE task.runtime_id = @runtime_id
|
||||
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
ORDER BY task.id;
|
||||
|
||||
@@ -395,6 +394,7 @@ FROM agent_task_queue AS task
|
||||
JOIN agent ON agent.id = task.agent_id
|
||||
WHERE task.runtime_id = @runtime_id
|
||||
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
ORDER BY task.id
|
||||
FOR UPDATE OF task;
|
||||
@@ -415,6 +415,7 @@ WHERE EXISTS (
|
||||
WHERE task.chat_session_id = cs.id
|
||||
AND task.runtime_id = @runtime_id
|
||||
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
)
|
||||
ORDER BY cs.id
|
||||
@@ -437,6 +438,7 @@ FROM agent
|
||||
WHERE task.agent_id = agent.id
|
||||
AND task.runtime_id = @runtime_id
|
||||
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
|
||||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||||
RETURNING task.*;
|
||||
|
||||
@@ -483,13 +485,13 @@ WHERE runtime_id = $1 AND kind = 'user'
|
||||
RETURNING *;
|
||||
|
||||
-- name: UnbindNonOwnerAgentsFromRuntime :many
|
||||
-- The companion to CancelNonOwnerTasksByRuntime for a public-to-private
|
||||
-- transition. Only teammates lose the binding; the runtime owner's agents
|
||||
-- remain bound and usable after the machine becomes private again.
|
||||
-- Companion to CancelNonOwnerTasksByRuntime: preserve agents belonging to
|
||||
-- the runtime owner or to recipients who retain access.
|
||||
UPDATE agent
|
||||
SET runtime_id = NULL, updated_at = now()
|
||||
WHERE runtime_id = @runtime_id
|
||||
AND owner_id IS DISTINCT FROM @runtime_owner_id
|
||||
AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgentRuntime :exec
|
||||
@@ -663,3 +665,24 @@ SELECT count(*) FROM agent_task_queue WHERE runtime_id = $1;
|
||||
-- refused before any write when any of them belongs to someone else.
|
||||
SELECT id, owner_id FROM agent_runtime
|
||||
WHERE workspace_id = @workspace_id AND daemon_id = @daemon_id;
|
||||
|
||||
-- name: LockRuntimeShareMembers :many
|
||||
-- Keep grant validation and member removal serialized until commit. NOWAIT
|
||||
-- avoids the member-removal path's reverse member/runtime lock order.
|
||||
SELECT user_id FROM member
|
||||
WHERE workspace_id = @workspace_id AND user_id = ANY(@user_ids::uuid[])
|
||||
ORDER BY user_id
|
||||
FOR KEY SHARE NOWAIT;
|
||||
|
||||
-- name: LockRuntimeShareMemberRemoval :one
|
||||
-- Prevent new grants until membership deletion commits. Grant writers use
|
||||
-- NOWAIT on this row, so they never wait while holding a runtime we need.
|
||||
SELECT id FROM member
|
||||
WHERE workspace_id = @workspace_id AND user_id = @user_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- name: ListSharedAgentRuntimesForUpdate :many
|
||||
SELECT * FROM agent_runtime
|
||||
WHERE workspace_id = @workspace_id AND @user_id::uuid = ANY(shared_with)
|
||||
ORDER BY id
|
||||
FOR NO KEY UPDATE;
|
||||
|
||||
Reference in New Issue
Block a user