mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 22:17:11 +02:00
Compare commits
23 Commits
fix/docs-r
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a5ef0aa8 | ||
|
|
b8907dda8d | ||
|
|
6cd49e132d | ||
|
|
a6db465e46 | ||
|
|
965561a6cc | ||
|
|
163f34f918 | ||
|
|
2317533da4 | ||
|
|
d81e6a14a6 | ||
|
|
e198a67f8f | ||
|
|
0ed16fc1b1 | ||
|
|
746f33a38b | ||
|
|
aa9305f7e4 | ||
|
|
63800f05ff | ||
|
|
133a1f1c16 | ||
|
|
b1b66ab05d | ||
|
|
0fc9641bf6 | ||
|
|
4223d32b37 | ||
|
|
b2307a5ee9 | ||
|
|
c85c43ed0e | ||
|
|
eecb3a2bc8 | ||
|
|
2c1478a69c | ||
|
|
bf31fa4b39 | ||
|
|
9b45e0d4a6 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build, type check, and test
|
||||
run: pnpm build && pnpm typecheck && pnpm test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -3,7 +3,8 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -17,6 +18,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag name
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
echo "Triggered by tag: $tag"
|
||||
if [[ "$tag" == *-dirty* ]]; then
|
||||
echo "::error::Refusing to release from dirty tag '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
# List subscribers of an issue
|
||||
multica issue subscriber list <issue-id>
|
||||
|
||||
# Subscribe yourself to an issue
|
||||
multica issue subscriber add <issue-id>
|
||||
|
||||
# Subscribe another member or agent by name
|
||||
multica issue subscriber add <issue-id> --user "Lambda"
|
||||
|
||||
# Unsubscribe yourself
|
||||
multica issue subscriber remove <issue-id>
|
||||
|
||||
# Unsubscribe another member or agent
|
||||
multica issue subscriber remove <issue-id> --user "Lambda"
|
||||
```
|
||||
|
||||
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
|
||||
|
||||
### Execution History
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -63,9 +63,13 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ publish:
|
||||
repo: multica
|
||||
# Align with our CLI release flow which pre-creates a *published* GitHub
|
||||
# Release via `gh release create`. The electron-builder default of
|
||||
# `publishingType: draft` conflicts with `existingType=release` and causes
|
||||
# `releaseType: draft` conflicts with `existingType=release` and causes
|
||||
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
|
||||
# which breaks electron-updater auto-update on installed clients.
|
||||
publishingType: release
|
||||
releaseType: release
|
||||
npmRebuild: false
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -64,10 +64,14 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
@@ -24,8 +24,14 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading)
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
|
||||
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
|
||||
// than silently drifting behind a mock reimplementation.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
@@ -36,7 +42,7 @@ vi.mock("@multica/core/auth", () => {
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
);
|
||||
return { useAuthStore };
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
|
||||
// Mock auth-cookie
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
@@ -25,8 +25,9 @@ function LoginPageContent() {
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
// the user's workspace list.
|
||||
const nextUrl = searchParams.get("next");
|
||||
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
|
||||
// cannot bounce the user off-origin after a successful login.
|
||||
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
|
||||
86
apps/web/app/auth/callback/page.test.tsx
Normal file
86
apps/web/app/auth/callback/page.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
||||
// exercised rather than silently diverging from the source of truth.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ loginWithGoogle: mockLoginWithGoogle }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockListWorkspaces,
|
||||
googleLogin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import CallbackPage from "./page";
|
||||
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(undefined);
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
|
||||
mockSearchParams.set("state", "next:https://evil.example");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
||||
});
|
||||
|
||||
it("honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -42,7 +42,9 @@ function CallbackContent() {
|
||||
const stateParts = state.split(",");
|
||||
const isDesktop = stateParts.includes("platform:desktop");
|
||||
const nextPart = stateParts.find((p) => p.startsWith("next:"));
|
||||
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
|
||||
// Strip "next:" prefix, then drop anything that isn't a safe relative path
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
|
||||
@@ -279,6 +279,31 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
title: "CLI Autopilot, Cmd+K & Daemon Identity",
|
||||
changes: [],
|
||||
features: [
|
||||
"CLI `autopilot` commands for managing scheduled and triggered automations",
|
||||
"CLI `issue subscriber` commands for subscription management",
|
||||
"Cmd+K palette extended — theme toggle, quick new issue/project, copy link, switch workspace",
|
||||
"Project and sub-issue progress as optional card properties on the issue list",
|
||||
"Persistent daemon UUID identity — CLI and desktop share one daemon across restarts and machine moves",
|
||||
"Sole-owner workspace leave preflight check",
|
||||
"Persist comment collapse state across sessions",
|
||||
],
|
||||
fixes: [
|
||||
"Agents now triggered on comments regardless of issue status",
|
||||
"Codex sandbox config fixed for macOS network access",
|
||||
"Editor bubble menu rewritten with @floating-ui/dom for reliable scroll hiding",
|
||||
"Autopilot creator automatically subscribed to autopilot-created issues",
|
||||
"Autopilot workspace ID correctly resolved for run-only tasks",
|
||||
"Desktop restricts `shell.openExternal` to http/https schemes (security)",
|
||||
"Duplicate agent names return 409 instead of silently failing",
|
||||
"New tabs in desktop inherit current workspace",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
|
||||
@@ -279,6 +279,31 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
title: "CLI Autopilot、Cmd+K 与 Daemon 身份",
|
||||
changes: [],
|
||||
features: [
|
||||
"CLI `autopilot` 命令,管理定时和触发式自动化",
|
||||
"CLI `issue subscriber` 订阅管理命令",
|
||||
"Cmd+K 命令面板扩展——主题切换、快速创建 Issue/项目、复制链接、切换工作区",
|
||||
"Issue 列表卡片可选显示项目和子 Issue 进度",
|
||||
"Daemon 持久化 UUID 身份——CLI 和桌面应用共用同一个 daemon,跨重启和机器迁移保持一致",
|
||||
"唯一所有者退出工作区的前置检查",
|
||||
"评论折叠状态跨会话持久化",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 现在在任意 Issue 状态下都会响应评论触发",
|
||||
"修复 Codex 沙箱在 macOS 上的网络访问配置",
|
||||
"编辑器气泡菜单改用 @floating-ui/dom 重写,滚动时正确隐藏",
|
||||
"Autopilot 创建者自动订阅其生成的 Issue",
|
||||
"Autopilot run-only 任务正确解析工作区 ID",
|
||||
"桌面应用 `shell.openExternal` 限制仅允许 http/https 协议(安全)",
|
||||
"重名 Agent 创建返回 409 而非静默失败",
|
||||
"桌面应用新建标签页继承当前工作区",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
|
||||
interval: 5s
|
||||
@@ -55,7 +56,9 @@ services:
|
||||
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -71,6 +74,7 @@ services:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { createAuthStore } from "./store";
|
||||
export type { AuthStoreOptions, AuthState } from "./store";
|
||||
export { sanitizeNextUrl } from "./utils";
|
||||
|
||||
import type { createAuthStore as CreateAuthStoreFn } from "./store";
|
||||
|
||||
|
||||
45
packages/core/auth/utils.test.ts
Normal file
45
packages/core/auth/utils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeNextUrl } from "./utils";
|
||||
|
||||
describe("sanitizeNextUrl", () => {
|
||||
it("accepts single-slash relative paths", () => {
|
||||
expect(sanitizeNextUrl("/issues")).toBe("/issues");
|
||||
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
|
||||
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
|
||||
"/issues?tab=assigned#top",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for null or empty input", () => {
|
||||
expect(sanitizeNextUrl(null)).toBeNull();
|
||||
expect(sanitizeNextUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute URLs", () => {
|
||||
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects javascript: and other non-http schemes", () => {
|
||||
// Caught by the leading-slash rule, but named here so future edits
|
||||
// to the regex don't silently drop protection against this vector.
|
||||
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(sanitizeNextUrl("//evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing backslashes", () => {
|
||||
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing control characters", () => {
|
||||
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
|
||||
});
|
||||
});
|
||||
20
packages/core/auth/utils.ts
Normal file
20
packages/core/auth/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Validate a post-login redirect URL and return it only if safe to follow.
|
||||
*
|
||||
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
|
||||
* `null` for unsafe or empty input — call sites decide the fallback so this
|
||||
* helper never overloads a specific path with "user did not pass next".
|
||||
*
|
||||
* Rejects:
|
||||
* - `null` / empty string
|
||||
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
|
||||
* - protocol-relative URLs (`//evil.com`)
|
||||
* - paths containing backslashes (Windows-style or `/\\host`)
|
||||
* - paths containing ASCII control characters (`\x00`–`\x1f`)
|
||||
*/
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface RuntimeDevice {
|
||||
name: string;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
provider: string;
|
||||
launch_header: string;
|
||||
status: "online" | "offline";
|
||||
device_info: string;
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@@ -178,6 +178,7 @@ export function AgentDetail({
|
||||
{activeTab === "custom_args" && (
|
||||
<CustomArgsTab
|
||||
agent={agent}
|
||||
runtimeDevice={runtimeDevice}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
@@ -29,9 +29,11 @@ function entriesToArgs(entries: ArgEntry[]): string[] {
|
||||
|
||||
export function CustomArgsTab({
|
||||
agent,
|
||||
runtimeDevice,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimeDevice?: RuntimeDevice;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ArgEntry[]>(
|
||||
@@ -69,6 +71,8 @@ export function CustomArgsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const launchHeader = runtimeDevice?.launch_header;
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -77,9 +81,17 @@ export function CustomArgsTab({
|
||||
Custom Arguments
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Additional CLI arguments appended to the agent command at launch
|
||||
(e.g. --model claude-sonnet-4-20250514)
|
||||
Additional CLI arguments appended to the agent command at launch.
|
||||
Supported flags depend on the agent's CLI.
|
||||
</p>
|
||||
{launchHeader && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Launch mode:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{launchHeader} <your args>
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -99,7 +111,7 @@ export function CustomArgsTab({
|
||||
<Input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, e.target.value)}
|
||||
placeholder="--model claude-sonnet-4-20250514"
|
||||
placeholder="--flag value"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
|
||||
174
packages/views/agents/components/tabs/tasks-tab.test.tsx
Normal file
174
packages/views/agents/components/tabs/tasks-tab.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, AgentTask, Issue } from "@multica/core/types";
|
||||
|
||||
const mockListAgentTasks = vi.hoisted(() => vi.fn());
|
||||
const mockListIssues = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useWorkspacePaths: () => actual.paths.workspace("test"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listAgentTasks: (...args: unknown[]) => mockListAgentTasks(...args),
|
||||
listIssues: (...args: unknown[]) => mockListIssues(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../navigation", () => ({
|
||||
AppLink: ({ children, href, ...props }: any) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { TasksTab } from "./tasks-tab";
|
||||
|
||||
const agent: Agent = {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "runtime-1",
|
||||
name: "Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
|
||||
function renderTasksTab(tasks: AgentTask[], issues: Issue[]) {
|
||||
mockListAgentTasks.mockResolvedValue(tasks);
|
||||
mockListIssues.mockImplementation(
|
||||
({ open_only, status }: { open_only?: boolean; status?: string }) =>
|
||||
Promise.resolve({
|
||||
issues: open_only ? issues : status === "done" ? [] : [],
|
||||
total: open_only ? issues.length : 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TasksTab agent={agent} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TasksTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses workspace-scoped issue detail paths when issue data is loaded", async () => {
|
||||
renderTasksTab(
|
||||
[
|
||||
{
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "issue-1",
|
||||
status: "queued",
|
||||
priority: 1,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "MUL-1",
|
||||
title: "Fix agent task routing",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 1,
|
||||
due_date: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const title = await screen.findByText("Fix agent task routing");
|
||||
const link = title.closest("a");
|
||||
|
||||
expect(link?.getAttribute("href")).toBe("/test/issues/issue-1");
|
||||
});
|
||||
|
||||
it("keeps task rows clickable when the issue is missing from the list query", async () => {
|
||||
renderTasksTab(
|
||||
[
|
||||
{
|
||||
id: "task-2",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "12345678-fallback",
|
||||
status: "completed",
|
||||
priority: 1,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: "2026-04-16T01:00:00Z",
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListAgentTasks).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
const title = await screen.findByText("Issue 12345678...");
|
||||
const link = title.closest("a");
|
||||
|
||||
expect(link?.getAttribute("href")).toBe("/test/issues/12345678-fallback");
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,17 @@ import type { Agent, AgentTask } from "@multica/core/types";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppLink } from "../../../navigation";
|
||||
import { taskStatusConfig } from "../../config";
|
||||
|
||||
export function TasksTab({ agent }: { agent: Agent }) {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,18 +85,16 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
const issue = issueMap.get(task.issue_id);
|
||||
const isActive = task.status === "running" || task.status === "dispatched";
|
||||
const isRunning = task.status === "running";
|
||||
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
|
||||
isRunning
|
||||
? "border-success/40 bg-success/5"
|
||||
: task.status === "dispatched"
|
||||
? "border-info/40 bg-info/5"
|
||||
: ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isRunning
|
||||
? "border-success/40 bg-success/5"
|
||||
: task.status === "dispatched"
|
||||
? "border-info/40 bg-info/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${config.color} ${
|
||||
isRunning ? "animate-spin" : ""
|
||||
@@ -110,7 +111,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
{issue?.title ?? `Issue ${task.issue_id.slice(0, 8)}...`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isRunning && task.started_at
|
||||
? `Started ${new Date(task.started_at).toLocaleString()}`
|
||||
: task.status === "dispatched" && task.dispatched_at
|
||||
@@ -125,7 +126,17 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
<span className={`shrink-0 text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={task.id}
|
||||
href={paths.issueDetail(task.issue_id)}
|
||||
className={`${rowClassName} text-foreground no-underline hover:no-underline`}
|
||||
>
|
||||
{content}
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -52,9 +52,9 @@ function formatDate(date: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
|
||||
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
|
||||
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
|
||||
running: { label: "Running", color: "text-blue-500", icon: Loader2, spin: true },
|
||||
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
@@ -66,7 +66,7 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
|
||||
@@ -34,11 +34,16 @@ export function useChatResize(
|
||||
if (!parent) return;
|
||||
|
||||
const update = () => {
|
||||
boundsRef.current = {
|
||||
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
|
||||
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
|
||||
};
|
||||
setBoundsReady(true);
|
||||
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
|
||||
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
|
||||
setBoundsReady(true); // idempotent once true
|
||||
// Only trigger a re-render if the bounds actually changed. Without this
|
||||
// guard, any spurious ResizeObserver notification (including sub-pixel
|
||||
// layout jitter during mount) schedules a setState that feeds back into
|
||||
// the observer, producing "Maximum update depth exceeded".
|
||||
const prev = boundsRef.current;
|
||||
if (prev.maxW === maxW && prev.maxH === maxH) return;
|
||||
boundsRef.current = { maxW, maxH };
|
||||
setRevision((r) => r + 1);
|
||||
};
|
||||
|
||||
|
||||
@@ -697,9 +697,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{issue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { AppLink, useNavigation } from "../navigation";
|
||||
import {
|
||||
@@ -78,6 +78,16 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
|
||||
// Stable empty arrays for query defaults. Using an inline `= []` default on
|
||||
// `useQuery` creates a new array reference on every render when `data` is
|
||||
// undefined (e.g. query disabled or loading) — which in turn breaks any
|
||||
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
|
||||
// re-render loops when the effect itself calls `setState`.
|
||||
const EMPTY_PINS: PinnedItem[] = [];
|
||||
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
|
||||
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
|
||||
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
|
||||
|
||||
// Nav items reference WorkspacePaths method names so they can be resolved
|
||||
// against the current workspace slug at render time (see AppSidebar body).
|
||||
// Only parameterless paths are valid nav destinations.
|
||||
@@ -139,7 +149,7 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
isActive={isActive}
|
||||
render={<AppLink href={href} />}
|
||||
render={<AppLink href={href} draggable={false} />}
|
||||
onClick={(event) => {
|
||||
if (wasDragged.current) {
|
||||
wasDragged.current = false;
|
||||
@@ -147,7 +157,10 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
|
||||
return;
|
||||
}
|
||||
}}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground",
|
||||
isDragging && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{pin.item_type === "issue" && pin.status ? (
|
||||
/* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */
|
||||
@@ -199,11 +212,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
const logout = useLogout();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const p = useWorkspacePaths();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
|
||||
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
|
||||
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
|
||||
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
const { data: inboxItems = EMPTY_INBOX } = useQuery({
|
||||
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
||||
queryFn: () => api.listInbox(),
|
||||
enabled: !!wsId,
|
||||
@@ -213,24 +226,42 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
const { data: pinnedItems = EMPTY_PINS } = useQuery({
|
||||
...pinListOptions(wsId ?? "", userId ?? ""),
|
||||
enabled: !!wsId && !!userId,
|
||||
});
|
||||
const deletePin = useDeletePin();
|
||||
const reorderPins = useReorderPins();
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
// Local presentational copy of pinnedItems for drop-animation stability.
|
||||
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
|
||||
// write (our own optimistic update, or a WS refetch) cannot reorder the
|
||||
// DOM under dnd-kit while its drop animation is still interpolating.
|
||||
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
|
||||
const isDraggingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current) {
|
||||
setLocalPinned(pinnedItems);
|
||||
}
|
||||
}, [pinnedItems]);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
isDraggingRef.current = true;
|
||||
}, []);
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
isDraggingRef.current = false;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
|
||||
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
|
||||
const oldIndex = localPinned.findIndex((p) => p.id === active.id);
|
||||
const newIndex = localPinned.findIndex((p) => p.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
|
||||
const reordered = arrayMove(localPinned, oldIndex, newIndex);
|
||||
setLocalPinned(reordered);
|
||||
reorderPins.mutate(reordered);
|
||||
},
|
||||
[pinnedItems, reorderPins],
|
||||
[localPinned, reorderPins],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -445,7 +476,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{pinnedItems.length > 0 && (
|
||||
{localPinned.length > 0 && (
|
||||
<Collapsible defaultOpen>
|
||||
<SidebarGroup className="group/pinned">
|
||||
<SidebarGroupLabel
|
||||
@@ -454,14 +485,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
>
|
||||
<span>Pinned</span>
|
||||
<ChevronRight className="!size-3 ml-1 stroke-[2.5] transition-transform duration-200 group-data-[panel-open]/trigger:rotate-90" />
|
||||
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{pinnedItems.length}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{localPinned.length}</span>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={localPinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{pinnedItems.map((pin: PinnedItem) => (
|
||||
{localPinned.map((pin: PinnedItem) => (
|
||||
<SortablePinItem
|
||||
key={pin.id}
|
||||
pin={pin}
|
||||
|
||||
@@ -309,7 +309,8 @@ function Start-LocalInstall {
|
||||
Write-Host ""
|
||||
Write-Host " multica setup self-host " -NoNewline; Write-Host "# Configure + authenticate + start daemon" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
Write-Host " Default verification code: 888888"
|
||||
Write-Host " Login: configure RESEND_API_KEY in .env for email codes,"
|
||||
Write-Host " or set APP_ENV=development in .env to enable the dev master code 888888."
|
||||
Write-Host ""
|
||||
Write-Host " To stop all services:"
|
||||
Write-Host ' $env:MULTICA_MODE="stop"; irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex'
|
||||
|
||||
@@ -337,7 +337,8 @@ run_with_server() {
|
||||
printf "\n"
|
||||
printf " ${CYAN}multica setup self-host${RESET} # Configure + authenticate + start daemon\n"
|
||||
printf "\n"
|
||||
printf " Default verification code: ${BOLD}888888${RESET}\n"
|
||||
printf " ${BOLD}Login:${RESET} configure ${CYAN}RESEND_API_KEY${RESET} in .env for email codes,\n"
|
||||
printf " or set ${CYAN}APP_ENV=development${RESET} in .env to enable the dev master code ${BOLD}888888${RESET}.\n"
|
||||
printf "\n"
|
||||
printf " ${BOLD}To stop all services:${RESET}\n"
|
||||
printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop\n"
|
||||
|
||||
@@ -88,6 +88,34 @@ var issueCommentDeleteCmd = &cobra.Command{
|
||||
RunE: runIssueCommentDelete,
|
||||
}
|
||||
|
||||
// Subscriber subcommands.
|
||||
|
||||
var issueSubscriberCmd = &cobra.Command{
|
||||
Use: "subscriber",
|
||||
Short: "Work with issue subscribers",
|
||||
}
|
||||
|
||||
var issueSubscriberListCmd = &cobra.Command{
|
||||
Use: "list <issue-id>",
|
||||
Short: "List subscribers of an issue",
|
||||
Args: exactArgs(1),
|
||||
RunE: runIssueSubscriberList,
|
||||
}
|
||||
|
||||
var issueSubscriberAddCmd = &cobra.Command{
|
||||
Use: "add <issue-id>",
|
||||
Short: "Subscribe a user or agent to an issue (defaults to the caller)",
|
||||
Args: exactArgs(1),
|
||||
RunE: runIssueSubscriberAdd,
|
||||
}
|
||||
|
||||
var issueSubscriberRemoveCmd = &cobra.Command{
|
||||
Use: "remove <issue-id>",
|
||||
Short: "Unsubscribe a user or agent from an issue (defaults to the caller)",
|
||||
Args: exactArgs(1),
|
||||
RunE: runIssueSubscriberRemove,
|
||||
}
|
||||
|
||||
// Execution history subcommands.
|
||||
|
||||
var issueRunsCmd = &cobra.Command{
|
||||
@@ -123,6 +151,7 @@ func init() {
|
||||
issueCmd.AddCommand(issueAssignCmd)
|
||||
issueCmd.AddCommand(issueStatusCmd)
|
||||
issueCmd.AddCommand(issueCommentCmd)
|
||||
issueCmd.AddCommand(issueSubscriberCmd)
|
||||
issueCmd.AddCommand(issueRunsCmd)
|
||||
issueCmd.AddCommand(issueRunMessagesCmd)
|
||||
issueCmd.AddCommand(issueSearchCmd)
|
||||
@@ -131,6 +160,10 @@ func init() {
|
||||
issueCommentCmd.AddCommand(issueCommentAddCmd)
|
||||
issueCommentCmd.AddCommand(issueCommentDeleteCmd)
|
||||
|
||||
issueSubscriberCmd.AddCommand(issueSubscriberListCmd)
|
||||
issueSubscriberCmd.AddCommand(issueSubscriberAddCmd)
|
||||
issueSubscriberCmd.AddCommand(issueSubscriberRemoveCmd)
|
||||
|
||||
// issue list
|
||||
issueListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
issueListCmd.Flags().String("status", "", "Filter by status")
|
||||
@@ -198,6 +231,17 @@ func init() {
|
||||
issueSearchCmd.Flags().Int("limit", 20, "Maximum number of results to return")
|
||||
issueSearchCmd.Flags().Bool("include-closed", false, "Include done and cancelled issues")
|
||||
issueSearchCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// issue subscriber list
|
||||
issueSubscriberListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// issue subscriber add
|
||||
issueSubscriberAddCmd.Flags().String("user", "", "Member or agent name to subscribe (defaults to the caller)")
|
||||
issueSubscriberAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// issue subscriber remove
|
||||
issueSubscriberRemoveCmd.Flags().String("user", "", "Member or agent name to unsubscribe (defaults to the caller)")
|
||||
issueSubscriberRemoveCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -918,6 +962,100 @@ func runIssueSearch(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscriber commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runIssueSubscriberList(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var subscribers []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/subscribers", &subscribers); err != nil {
|
||||
return fmt.Errorf("list subscribers: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, subscribers)
|
||||
}
|
||||
|
||||
headers := []string{"USER_TYPE", "USER_ID", "REASON", "CREATED"}
|
||||
rows := make([][]string, 0, len(subscribers))
|
||||
for _, s := range subscribers {
|
||||
created := strVal(s, "created_at")
|
||||
if len(created) >= 16 {
|
||||
created = created[:16]
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
strVal(s, "user_type"),
|
||||
truncateID(strVal(s, "user_id")),
|
||||
strVal(s, "reason"),
|
||||
created,
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIssueSubscriberAdd(cmd *cobra.Command, args []string) error {
|
||||
return runIssueSubscriberMutation(cmd, args[0], "subscribe")
|
||||
}
|
||||
|
||||
func runIssueSubscriberRemove(cmd *cobra.Command, args []string) error {
|
||||
return runIssueSubscriberMutation(cmd, args[0], "unsubscribe")
|
||||
}
|
||||
|
||||
// runIssueSubscriberMutation shares subscribe/unsubscribe logic — both endpoints
|
||||
// take the same request body and only differ in the path.
|
||||
func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{}
|
||||
userName, _ := cmd.Flags().GetString("user")
|
||||
if userName != "" {
|
||||
uType, uID, resolveErr := resolveAssignee(ctx, client, userName)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve user: %w", resolveErr)
|
||||
}
|
||||
body["user_type"] = uType
|
||||
body["user_id"] = uID
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
path := "/api/issues/" + issueID + "/" + action
|
||||
if err := client.PostJSON(ctx, path, body, &result); err != nil {
|
||||
return fmt.Errorf("%s issue: %w", action, err)
|
||||
}
|
||||
|
||||
target := "caller"
|
||||
if userName != "" {
|
||||
target = userName
|
||||
}
|
||||
if action == "subscribe" {
|
||||
fmt.Fprintf(os.Stderr, "Subscribed %s to issue %s.\n", target, truncateID(issueID))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Unsubscribed %s from issue %s.\n", target, truncateID(issueID))
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
return nil
|
||||
}
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -137,6 +137,150 @@ func TestResolveAssignee(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSubscriberList(t *testing.T) {
|
||||
subscribersResp := []map[string]any{
|
||||
{
|
||||
"issue_id": "issue-1",
|
||||
"user_type": "member",
|
||||
"user_id": "user-1111",
|
||||
"reason": "creator",
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"issue_id": "issue-1",
|
||||
"user_type": "agent",
|
||||
"user_id": "agent-3333",
|
||||
"reason": "manual",
|
||||
"created_at": "2026-04-01T11:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
json.NewEncoder(w).Encode(subscribersResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
var got []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/issues/issue-1/subscribers", &got); err != nil {
|
||||
t.Fatalf("GetJSON: %v", err)
|
||||
}
|
||||
if gotPath != "/api/issues/issue-1/subscribers" {
|
||||
t.Errorf("unexpected path: %s", gotPath)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 subscribers, got %d", len(got))
|
||||
}
|
||||
if got[0]["user_type"] != "member" || got[1]["user_type"] != "agent" {
|
||||
t.Errorf("unexpected subscriber ordering: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueSubscriberMutationBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
user string
|
||||
members []map[string]any
|
||||
agents []map[string]any
|
||||
wantPath string
|
||||
wantBody map[string]any
|
||||
}{
|
||||
{
|
||||
name: "subscribe caller (no user flag)",
|
||||
action: "subscribe",
|
||||
user: "",
|
||||
wantPath: "/api/issues/issue-1/subscribe",
|
||||
wantBody: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "unsubscribe caller",
|
||||
action: "unsubscribe",
|
||||
user: "",
|
||||
wantPath: "/api/issues/issue-1/unsubscribe",
|
||||
wantBody: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "subscribe a member by name",
|
||||
action: "subscribe",
|
||||
user: "alice",
|
||||
members: []map[string]any{{"user_id": "user-1111", "name": "Alice Smith"}},
|
||||
wantPath: "/api/issues/issue-1/subscribe",
|
||||
wantBody: map[string]any{"user_type": "member", "user_id": "user-1111"},
|
||||
},
|
||||
{
|
||||
name: "subscribe an agent by name",
|
||||
action: "subscribe",
|
||||
user: "codebot",
|
||||
agents: []map[string]any{{"id": "agent-3333", "name": "CodeBot"}},
|
||||
wantPath: "/api/issues/issue-1/subscribe",
|
||||
wantBody: map[string]any{"user_type": "agent", "user_id": "agent-3333"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/workspaces/ws-1/members":
|
||||
json.NewEncoder(w).Encode(tt.members)
|
||||
return
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(tt.agents)
|
||||
return
|
||||
}
|
||||
gotPath = r.URL.Path
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
json.NewEncoder(w).Encode(map[string]bool{"subscribed": tt.action == "subscribe"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
body := map[string]any{}
|
||||
if tt.user != "" {
|
||||
uType, uID, err := resolveAssignee(ctx, client, tt.user)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAssignee: %v", err)
|
||||
}
|
||||
body["user_type"] = uType
|
||||
body["user_id"] = uID
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
path := "/api/issues/issue-1/" + tt.action
|
||||
if err := client.PostJSON(ctx, path, body, &result); err != nil {
|
||||
t.Fatalf("PostJSON: %v", err)
|
||||
}
|
||||
|
||||
if gotPath != tt.wantPath {
|
||||
t.Errorf("path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
for k, want := range tt.wantBody {
|
||||
if gotBody[k] != want {
|
||||
t.Errorf("body[%q] = %v, want %v", k, gotBody[k], want)
|
||||
}
|
||||
}
|
||||
if len(tt.wantBody) == 0 && len(gotBody) != 0 {
|
||||
t.Errorf("expected empty body, got %+v", gotBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIssueStatuses(t *testing.T) {
|
||||
expected := map[string]bool{
|
||||
"backlog": true,
|
||||
@@ -156,4 +300,3 @@ func TestValidIssueStatuses(t *testing.T) {
|
||||
t.Errorf("validIssueStatuses has %d entries, expected %d", len(validIssueStatuses), len(expected))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,27 @@ func clearTasks(t *testing.T, issueID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// latestTriggerCommentID returns the trigger_comment_id of the most recently
|
||||
// created queued/dispatched task for the given issue, or empty string if none.
|
||||
func latestTriggerCommentID(t *testing.T, issueID string) string {
|
||||
t.Helper()
|
||||
var triggerID *string
|
||||
err := testPool.QueryRow(context.Background(),
|
||||
`SELECT trigger_comment_id::text
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
issueID).Scan(&triggerID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch trigger_comment_id: %v", err)
|
||||
}
|
||||
if triggerID == nil {
|
||||
return ""
|
||||
}
|
||||
return *triggerID
|
||||
}
|
||||
|
||||
// getAgentID returns the ID of the first agent in the test workspace.
|
||||
func getAgentID(t *testing.T) string {
|
||||
t.Helper()
|
||||
@@ -228,6 +249,25 @@ func TestCommentTriggerOnComment(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Regression guard for #1301: the assignee on_comment path must record
|
||||
// the NEW reply as trigger_comment_id, not the thread root. Otherwise
|
||||
// the daemon feeds stale content to the agent prompt, which with
|
||||
// `--resume` sessions surfaces as "already replied, no further action".
|
||||
// Reply placement (flat-thread grouping) is handled downstream in
|
||||
// TaskService.createAgentComment, not here.
|
||||
t.Run("reply records new comment id (not thread root) as trigger_comment_id", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
threadID := postCommentAsAgent(t, issueID, "First pass analysis.", agentID, nil)
|
||||
replyID := postComment(t, issueID, "Please also check the edge case", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Fatalf("expected 1 pending task, got %d", n)
|
||||
}
|
||||
if got := latestTriggerCommentID(t, issueID); got != replyID {
|
||||
t.Errorf("trigger_comment_id = %q, want reply id %q (thread root was %q)",
|
||||
got, replyID, threadID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread without mentions suppresses trigger", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread.
|
||||
|
||||
@@ -2,6 +2,7 @@ package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -27,7 +28,7 @@ var ErrRepoNotConfigured = errors.New("repo is not configured for this workspace
|
||||
type workspaceState struct {
|
||||
workspaceID string
|
||||
runtimeIDs []string
|
||||
reposVersion string // stored for future use: skip refresh when version unchanged
|
||||
reposVersion string // stored for future use: skip refresh when version unchanged
|
||||
allowedRepoURLs map[string]struct{}
|
||||
lastRepoSyncErr string
|
||||
repoRefreshMu sync.Mutex
|
||||
@@ -1006,8 +1007,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
taskStart := time.Now()
|
||||
|
||||
var customArgs []string
|
||||
var mcpConfig json.RawMessage
|
||||
if task.Agent != nil {
|
||||
customArgs = task.Agent.CustomArgs
|
||||
mcpConfig = task.Agent.McpConfig
|
||||
}
|
||||
execOpts := agent.ExecOptions{
|
||||
Cwd: env.WorkDir,
|
||||
@@ -1015,6 +1018,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
Timeout: d.cfg.AgentTimeout,
|
||||
ResumeSessionID: task.PriorSessionID,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
}
|
||||
|
||||
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
//
|
||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||
// Codex: skills → handled separately in Prepare via codex-home
|
||||
// Copilot: skills → {workDir}/.agent_context/skills/{name}/SKILL.md (via AGENTS.md references)
|
||||
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
|
||||
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
|
||||
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
||||
@@ -54,6 +54,12 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
case "claude":
|
||||
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".claude", "skills")
|
||||
case "copilot":
|
||||
// GitHub Copilot CLI natively discovers project-level skills from
|
||||
// .github/skills/<name>/SKILL.md (takes precedence over user-level
|
||||
// skills in ~/.copilot/skills/).
|
||||
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
|
||||
skillsDir = filepath.Join(workDir, ".github", "skills")
|
||||
case "opencode":
|
||||
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
||||
|
||||
@@ -479,6 +479,56 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteContextFilesCopilotNativeSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "copilot-skill-test",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Go Conventions",
|
||||
Content: "Follow Go conventions.",
|
||||
Files: []SkillFileContextForEnv{
|
||||
{Path: "templates/example.go", Content: "package main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, "copilot", ctx); err != nil {
|
||||
t.Fatalf("writeContextFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Copilot CLI natively discovers project-level skills from .github/skills/.
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".github", "skills", "go-conventions", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .github/skills/go-conventions/SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
||||
t.Error("SKILL.md missing content")
|
||||
}
|
||||
|
||||
// Supporting files should also be under .github/skills/.
|
||||
supportFile, err := os.ReadFile(filepath.Join(dir, ".github", "skills", "go-conventions", "templates", "example.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read supporting file: %v", err)
|
||||
}
|
||||
if string(supportFile) != "package main" {
|
||||
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
|
||||
}
|
||||
|
||||
// .agent_context/skills/ should NOT exist for Copilot.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
|
||||
t.Error("expected .agent_context/skills/ to NOT exist for Copilot provider")
|
||||
}
|
||||
|
||||
// issue_context.md should still be in .agent_context/.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
|
||||
t.Error("expected .agent_context/issue_context.md to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
//
|
||||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For Copilot: writes {workDir}/AGENTS.md (Copilot CLI natively reads AGENTS.md)
|
||||
// For Copilot: writes {workDir}/AGENTS.md (skills discovered natively from .github/skills/)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
|
||||
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
|
||||
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
|
||||
@@ -169,7 +169,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("When referencing issues or people in comments, use the mention format so they render as interactive links:\n\n")
|
||||
b.WriteString("- **Issue**: `[MUL-123](mention://issue/<issue-id>)` — renders as a clickable link to the issue\n")
|
||||
b.WriteString("- **Member**: `[@Name](mention://member/<user-id>)` — renders as a styled mention and sends a notification\n")
|
||||
b.WriteString("- **Agent**: `[@Name](mention://agent/<agent-id>)` — renders as a styled mention\n\n")
|
||||
b.WriteString("- **Agent**: `[@Name](mention://agent/<agent-id>)` — renders as a styled mention and re-triggers the agent\n\n")
|
||||
b.WriteString("⚠️ Agent and member mentions are **actions**, not text references: agent mentions enqueue a new task for the agent, and member mentions send a notification. ")
|
||||
b.WriteString("If you only want to refer to someone by name in prose (e.g. \"GPT-Boy is correct\"), write the plain name without the mention link.\n\n")
|
||||
b.WriteString("Use `multica issue list --output json` to look up issue IDs, and `multica workspace members --output json` for member IDs.\n\n")
|
||||
|
||||
b.WriteString("## Attachments\n\n")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package daemon
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// AgentEntry describes a single available agent CLI.
|
||||
type AgentEntry struct {
|
||||
Path string // path to CLI binary
|
||||
@@ -23,15 +25,15 @@ type RepoData struct {
|
||||
// Task represents a claimed task from the server.
|
||||
// Agent data (name, skills) is populated by the claim endpoint.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
@@ -46,6 +48,7 @@ type AgentData struct {
|
||||
Skills []SkillData `json:"skills"`
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
CustomArgs []string `json:"custom_args,omitempty"`
|
||||
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
|
||||
}
|
||||
|
||||
// SkillData represents a structured skill for task execution.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -28,7 +30,9 @@ type AgentResponse struct {
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv map[string]string `json:"custom_env"`
|
||||
CustomArgs []string `json:"custom_args"`
|
||||
McpConfig json.RawMessage `json:"mcp_config"`
|
||||
CustomEnvRedacted bool `json:"custom_env_redacted"`
|
||||
McpConfigRedacted bool `json:"mcp_config_redacted"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
@@ -69,6 +73,11 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
customArgs = []string{}
|
||||
}
|
||||
|
||||
var mcpConfig json.RawMessage
|
||||
if a.McpConfig != nil {
|
||||
mcpConfig = json.RawMessage(a.McpConfig)
|
||||
}
|
||||
|
||||
return AgentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
@@ -81,6 +90,7 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
RuntimeConfig: rc,
|
||||
CustomEnv: customEnv,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
@@ -101,23 +111,23 @@ type RepoData struct {
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
@@ -133,6 +143,7 @@ type TaskAgentData struct {
|
||||
Skills []service.AgentSkillData `json:"skills,omitempty"`
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
CustomArgs []string `json:"custom_args,omitempty"`
|
||||
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
@@ -141,16 +152,16 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
json.Unmarshal(t.Result, &result)
|
||||
}
|
||||
return AgentTaskResponse{
|
||||
ID: uuidToString(t.ID),
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
RuntimeID: uuidToString(t.RuntimeID),
|
||||
IssueID: uuidToString(t.IssueID),
|
||||
Status: t.Status,
|
||||
Priority: t.Priority,
|
||||
DispatchedAt: timestampToPtr(t.DispatchedAt),
|
||||
StartedAt: timestampToPtr(t.StartedAt),
|
||||
CompletedAt: timestampToPtr(t.CompletedAt),
|
||||
Result: result,
|
||||
ID: uuidToString(t.ID),
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
RuntimeID: uuidToString(t.RuntimeID),
|
||||
IssueID: uuidToString(t.IssueID),
|
||||
Status: t.Status,
|
||||
Priority: t.Priority,
|
||||
DispatchedAt: timestampToPtr(t.DispatchedAt),
|
||||
StartedAt: timestampToPtr(t.StartedAt),
|
||||
CompletedAt: timestampToPtr(t.CompletedAt),
|
||||
Result: result,
|
||||
Error: textToPtr(t.Error),
|
||||
CreatedAt: timestampToString(t.CreatedAt),
|
||||
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
|
||||
@@ -200,9 +211,10 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if skills, ok := skillMap[resp.ID]; ok {
|
||||
resp.Skills = skills
|
||||
}
|
||||
// Redact custom_env for users who are not the agent owner or workspace owner/admin.
|
||||
// Redact sensitive fields for users who are not the agent owner or workspace owner/admin.
|
||||
if !canViewAgentEnv(a, userID, member.Role) {
|
||||
redactEnv(&resp)
|
||||
redactMcpConfig(&resp)
|
||||
}
|
||||
visible = append(visible, resp)
|
||||
}
|
||||
@@ -229,11 +241,12 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Redact custom_env for users who are not the agent owner or workspace owner/admin.
|
||||
// Redact sensitive fields for users who are not the agent owner or workspace owner/admin.
|
||||
userID := requestUserID(r)
|
||||
if member, ok := ctxMember(r.Context()); ok {
|
||||
if !canViewAgentEnv(agent, userID, member.Role) {
|
||||
redactEnv(&resp)
|
||||
redactMcpConfig(&resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,15 +262,38 @@ type CreateAgentRequest struct {
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv map[string]string `json:"custom_env"`
|
||||
CustomArgs []string `json:"custom_args"`
|
||||
McpConfig json.RawMessage `json:"mcp_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
func decodeJSONBodyWithRawFields(body io.Reader, dst any) (map[string]json.RawMessage, error) {
|
||||
payload, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]json.RawMessage{}
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
|
||||
var req CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
rawFields, err := decodeJSONBodyWithRawFields(r.Body, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
@@ -306,6 +342,11 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
ca = []byte("[]")
|
||||
}
|
||||
|
||||
var mc []byte
|
||||
if rawMcpConfig, ok := rawFields["mcp_config"]; ok && !bytes.Equal(bytes.TrimSpace(rawMcpConfig), []byte("null")) {
|
||||
mc = append([]byte(nil), rawMcpConfig...)
|
||||
}
|
||||
|
||||
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: req.Name,
|
||||
@@ -320,6 +361,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerID: parseUUID(ownerID),
|
||||
CustomEnv: ce,
|
||||
CustomArgs: ca,
|
||||
McpConfig: mc,
|
||||
})
|
||||
if err != nil {
|
||||
// Unique constraint on (workspace_id, name) — return a clear conflict error
|
||||
@@ -346,8 +388,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
@@ -357,6 +397,7 @@ type UpdateAgentRequest struct {
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv *map[string]string `json:"custom_env"`
|
||||
CustomArgs *[]string `json:"custom_args"`
|
||||
McpConfig *json.RawMessage `json:"mcp_config"`
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
@@ -384,6 +425,16 @@ func redactEnv(resp *AgentResponse) {
|
||||
resp.CustomEnvRedacted = true
|
||||
}
|
||||
|
||||
// redactMcpConfig removes the mcp_config value from the response when the caller is not
|
||||
// authorised to view it. The field is set to null; McpConfigRedacted is set to true so
|
||||
// callers know a config exists without seeing its contents (which may contain secrets).
|
||||
func redactMcpConfig(resp *AgentResponse) {
|
||||
if resp.McpConfig != nil {
|
||||
resp.McpConfig = nil
|
||||
resp.McpConfigRedacted = true
|
||||
}
|
||||
}
|
||||
|
||||
// canManageAgent checks whether the current user can update or archive an agent.
|
||||
// Only the agent owner or workspace owner/admin can manage any agent,
|
||||
// regardless of whether it is public or private.
|
||||
@@ -413,7 +464,8 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req UpdateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
rawFields, err := decodeJSONBodyWithRawFields(r.Body, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
@@ -445,6 +497,11 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
ca, _ := json.Marshal(*req.CustomArgs)
|
||||
params.CustomArgs = ca
|
||||
}
|
||||
rawMcpConfig, hasMcpConfig := rawFields["mcp_config"]
|
||||
shouldClearMcpConfig := hasMcpConfig && bytes.Equal(bytes.TrimSpace(rawMcpConfig), []byte("null"))
|
||||
if hasMcpConfig && !shouldClearMcpConfig {
|
||||
params.McpConfig = append([]byte(nil), rawMcpConfig...)
|
||||
}
|
||||
if req.RuntimeID != nil {
|
||||
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: parseUUID(*req.RuntimeID),
|
||||
@@ -467,13 +524,24 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
|
||||
}
|
||||
|
||||
agent, err := h.Queries.UpdateAgent(r.Context(), params)
|
||||
agent, err = h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// mcp_config: null in the request means explicitly clear the field.
|
||||
// COALESCE in UpdateAgent cannot set a column to NULL, so we use a dedicated query.
|
||||
if shouldClearMcpConfig {
|
||||
agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
slog.Warn("clear agent mcp_config failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to clear mcp_config: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp := agentToResponse(agent)
|
||||
slog.Info("agent updated", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", uuidToString(agent.WorkspaceID))...)
|
||||
userID := requestUserID(r)
|
||||
|
||||
@@ -263,14 +263,12 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
|
||||
// Resolve thread root: if the comment is a reply, agent should reply
|
||||
// to the thread root (matching frontend behavior where all replies
|
||||
// in a thread share the same top-level parent).
|
||||
replyTo := comment.ID
|
||||
if comment.ParentID.Valid {
|
||||
replyTo = comment.ParentID
|
||||
}
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, replyTo); err != nil {
|
||||
// Always use the current comment as the trigger so the agent reads
|
||||
// the actual new reply, not the thread root. Reply placement (flat
|
||||
// thread grouping) is handled downstream by createAgentComment,
|
||||
// which resolves parent_id to the thread root before posting. This
|
||||
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
|
||||
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +534,10 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn("failed to unmarshal agent custom_args", "agent_id", uuidToString(agent.ID), "error", err)
|
||||
}
|
||||
}
|
||||
var mcpConfig json.RawMessage
|
||||
if agent.McpConfig != nil {
|
||||
mcpConfig = json.RawMessage(agent.McpConfig)
|
||||
}
|
||||
resp.Agent = &TaskAgentData{
|
||||
ID: uuidToString(agent.ID),
|
||||
Name: agent.Name,
|
||||
@@ -541,6 +545,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
Skills: skills,
|
||||
CustomEnv: customEnv,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,6 +614,21 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Autopilot run_only task: resolve workspace from autopilot_run → autopilot.
|
||||
if task.AutopilotRunID.Valid && resp.WorkspaceID == "" {
|
||||
if run, err := h.Queries.GetAutopilotRun(r.Context(), task.AutopilotRunID); err == nil {
|
||||
if ap, err := h.Queries.GetAutopilot(r.Context(), run.AutopilotID); err == nil {
|
||||
resp.WorkspaceID = uuidToString(ap.WorkspaceID)
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), ap.WorkspaceID); err == nil && ws.Repos != nil {
|
||||
var repos []RepoData
|
||||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||||
resp.Repos = repos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID), "prior_session", resp.PriorSessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": resp})
|
||||
}
|
||||
|
||||
@@ -1137,3 +1137,88 @@ func TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace(t *testing.T) {
|
||||
t.Fatalf("expected task status 'running' after StartTask, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #1276: ClaimTaskByRuntime must populate workspace_id in
|
||||
// the response for run_only autopilot tasks. Before the fix, resp.WorkspaceID
|
||||
// stayed empty because ClaimTaskByRuntime only handled IssueID and
|
||||
// ChatSessionID branches, causing the daemon's execenv to fail with
|
||||
// "workspace ID is required".
|
||||
func TestClaimTask_AutopilotRunOnly_PopulatesWorkspaceID(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var agentID, runtimeID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
||||
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
||||
t.Fatalf("setup: get agent: %v", err)
|
||||
}
|
||||
|
||||
var autopilotID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO autopilot (
|
||||
workspace_id, title, assignee_id, execution_mode,
|
||||
created_by_type, created_by_id
|
||||
)
|
||||
VALUES ($1, 'claim workspace fixture', $2, 'run_only', 'member', $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil {
|
||||
t.Fatalf("setup: create autopilot: %v", err)
|
||||
}
|
||||
defer testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
||||
|
||||
var runID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO autopilot_run (autopilot_id, source, status)
|
||||
VALUES ($1, 'manual', 'running')
|
||||
RETURNING id
|
||||
`, autopilotID).Scan(&runID); err != nil {
|
||||
t.Fatalf("setup: create autopilot_run: %v", err)
|
||||
}
|
||||
|
||||
// Create a queued task with only AutopilotRunID (no IssueID, no ChatSessionID).
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, autopilot_run_id
|
||||
)
|
||||
VALUES ($1, $2, NULL, 'queued', 0, $3)
|
||||
RETURNING id
|
||||
`, agentID, runtimeID, runID).Scan(&taskID); err != nil {
|
||||
t.Fatalf("setup: create autopilot task: %v", err)
|
||||
}
|
||||
defer testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
|
||||
testWorkspaceID, "test-daemon-claim")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("runtimeId", runtimeID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
testHandler.ClaimTaskByRuntime(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Task *struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Task == nil {
|
||||
t.Fatal("expected a task in response, got nil")
|
||||
}
|
||||
if resp.Task.WorkspaceID == "" {
|
||||
t.Fatal("ClaimTaskByRuntime for run_only autopilot: workspace_id is empty in response")
|
||||
}
|
||||
if resp.Task.WorkspaceID != testWorkspaceID {
|
||||
t.Fatalf("expected workspace_id %q, got %q", testWorkspaceID, resp.Task.WorkspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,81 @@ func withURLParam(req *http.Request, key, value string) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func handlerTestRuntimeID(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
var runtimeID string
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||
testWorkspaceID,
|
||||
).Scan(&runtimeID); err != nil {
|
||||
t.Fatalf("failed to load handler test runtime: %v", err)
|
||||
}
|
||||
|
||||
return runtimeID
|
||||
}
|
||||
|
||||
func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string {
|
||||
t.Helper()
|
||||
|
||||
var agentID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args, mcp_config
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4, '', '{}'::jsonb, '[]'::jsonb, $5)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, name, handlerTestRuntimeID(t), testUserID, mcpConfig).Scan(&agentID); err != nil {
|
||||
t.Fatalf("failed to create handler test agent: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
||||
})
|
||||
|
||||
return agentID
|
||||
}
|
||||
|
||||
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var mcpConfig []byte
|
||||
if err := testPool.QueryRow(context.Background(), `SELECT mcp_config FROM agent WHERE id = $1`, agentID).Scan(&mcpConfig); err != nil {
|
||||
t.Fatalf("failed to load agent mcp_config: %v", err)
|
||||
}
|
||||
|
||||
return mcpConfig
|
||||
}
|
||||
|
||||
func assertJSONEqual(t *testing.T, got []byte, want string) {
|
||||
t.Helper()
|
||||
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("failed to unmarshal got JSON %q: %v", string(got), err)
|
||||
}
|
||||
|
||||
var wantValue any
|
||||
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
|
||||
t.Fatalf("failed to unmarshal want JSON %q: %v", want, err)
|
||||
}
|
||||
|
||||
gotJSON, err := json.Marshal(gotValue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal normalized got JSON: %v", err)
|
||||
}
|
||||
wantJSON, err := json.Marshal(wantValue)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal normalized want JSON: %v", err)
|
||||
}
|
||||
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Fatalf("expected JSON %s, got %s", string(wantJSON), string(gotJSON))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCRUD(t *testing.T) {
|
||||
// Create
|
||||
w := httptest.NewRecorder()
|
||||
@@ -538,6 +613,99 @@ func TestAgentCRUD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentMcpConfigAbsentPreservesValue(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "Handler Mcp Preserve", []byte(`{"preset":"keep"}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"name": "Handler Mcp Preserve Updated",
|
||||
})
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated AgentResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("UpdateAgent: decode response: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, updated.McpConfig, `{"preset":"keep"}`)
|
||||
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"keep"}`)
|
||||
}
|
||||
|
||||
func TestUpdateAgentMcpConfigNullClearsValue(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "Handler Mcp Clear", []byte(`{"preset":"clear"}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"mcp_config": nil,
|
||||
})
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated AgentResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("UpdateAgent: decode response: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, updated.McpConfig, `null`)
|
||||
if fetchAgentMcpConfig(t, agentID) != nil {
|
||||
t.Fatalf("UpdateAgent: expected DB mcp_config to be SQL NULL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgentMcpConfigObjectUpdatesValue(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "Handler Mcp Update", []byte(`{"preset":"old"}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"mcp_config": map[string]any{"preset": "new"},
|
||||
})
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated AgentResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("UpdateAgent: decode response: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, updated.McpConfig, `{"preset":"new"}`)
|
||||
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"new"}`)
|
||||
}
|
||||
|
||||
func TestCreateAgentMcpConfigNullStoresSQLNull(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/agents", map[string]any{
|
||||
"name": "Handler Mcp Create Null",
|
||||
"runtime_id": handlerTestRuntimeID(t),
|
||||
"mcp_config": nil,
|
||||
"custom_env": map[string]string{},
|
||||
"custom_args": []string{},
|
||||
})
|
||||
testHandler.CreateAgent(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAgent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created AgentResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("CreateAgent: decode response: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
assertJSONEqual(t, created.McpConfig, `null`)
|
||||
if fetchAgentMcpConfig(t, created.ID) != nil {
|
||||
t.Fatalf("CreateAgent: expected DB mcp_config to be SQL NULL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCRUD(t *testing.T) {
|
||||
// List workspaces
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -9,24 +9,26 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type AgentRuntimeResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
DaemonID *string `json:"daemon_id"`
|
||||
Name string `json:"name"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
DaemonID *string `json:"daemon_id"`
|
||||
Name string `json:"name"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
Provider string `json:"provider"`
|
||||
LaunchHeader string `json:"launch_header"`
|
||||
Status string `json:"status"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
@@ -39,19 +41,20 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
}
|
||||
|
||||
return AgentRuntimeResponse{
|
||||
ID: uuidToString(rt.ID),
|
||||
WorkspaceID: uuidToString(rt.WorkspaceID),
|
||||
DaemonID: textToPtr(rt.DaemonID),
|
||||
Name: rt.Name,
|
||||
RuntimeMode: rt.RuntimeMode,
|
||||
Provider: rt.Provider,
|
||||
Status: rt.Status,
|
||||
DeviceInfo: rt.DeviceInfo,
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
UpdatedAt: timestampToString(rt.UpdatedAt),
|
||||
ID: uuidToString(rt.ID),
|
||||
WorkspaceID: uuidToString(rt.WorkspaceID),
|
||||
DaemonID: textToPtr(rt.DaemonID),
|
||||
Name: rt.Name,
|
||||
RuntimeMode: rt.RuntimeMode,
|
||||
Provider: rt.Provider,
|
||||
LaunchHeader: agent.LaunchHeader(rt.Provider),
|
||||
Status: rt.Status,
|
||||
DeviceInfo: rt.DeviceInfo,
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
UpdatedAt: timestampToString(rt.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,12 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Default to current user as member; allow specifying another user/agent
|
||||
targetUserID := requestUserID(r)
|
||||
targetUserType := "member"
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
// Default target: the caller, derived via resolveActor so an agent caller
|
||||
// (X-Agent-ID set) subscribes itself rather than the underlying member.
|
||||
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
targetUserType := callerActorType
|
||||
targetUserID := callerActorID
|
||||
var req struct {
|
||||
UserID *string `json:"user_id"`
|
||||
UserType *string `json:"user_type"`
|
||||
@@ -76,7 +79,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserType = *req.UserType
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
|
||||
return
|
||||
@@ -93,9 +95,7 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
callerID := requestUserID(r)
|
||||
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
|
||||
h.publish(protocol.EventSubscriberAdded, workspaceID, callerActorType, callerActorID, map[string]any{
|
||||
"issue_id": issueID,
|
||||
"user_type": targetUserType,
|
||||
"user_id": targetUserID,
|
||||
@@ -114,8 +114,12 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID := requestUserID(r)
|
||||
targetUserType := "member"
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
// Default target: the caller, derived via resolveActor so an agent caller
|
||||
// (X-Agent-ID set) unsubscribes itself rather than the underlying member.
|
||||
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
targetUserType := callerActorType
|
||||
targetUserID := callerActorID
|
||||
var req struct {
|
||||
UserID *string `json:"user_id"`
|
||||
UserType *string `json:"user_type"`
|
||||
@@ -130,7 +134,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserType = *req.UserType
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
|
||||
return
|
||||
@@ -146,9 +149,7 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
callerID := requestUserID(r)
|
||||
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{
|
||||
h.publish(protocol.EventSubscriberRemoved, workspaceID, callerActorType, callerActorID, map[string]any{
|
||||
"issue_id": issueID,
|
||||
"user_type": targetUserType,
|
||||
"user_id": targetUserID,
|
||||
|
||||
@@ -220,6 +220,78 @@ func TestSubscriberAPI(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AgentCallerSubscribesItself", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
// Look up the agent created by the handler test fixture.
|
||||
var agentID string
|
||||
err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe with X-Agent-ID set — no body, so the handler must default
|
||||
// to subscribing the agent itself (not the member behind X-User-ID).
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
testHandler.SubscribeToIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
agentSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "agent",
|
||||
UserID: parseUUID(agentID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (agent): %v", err)
|
||||
}
|
||||
if !agentSubscribed {
|
||||
t.Fatal("expected agent to be subscribed in DB when X-Agent-ID is set")
|
||||
}
|
||||
|
||||
memberSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "member",
|
||||
UserID: parseUUID(testUserID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (member): %v", err)
|
||||
}
|
||||
if memberSubscribed {
|
||||
t.Fatal("member must not be auto-subscribed when caller is an agent")
|
||||
}
|
||||
|
||||
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
testHandler.UnsubscribeFromIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
agentSubscribed, err = testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "agent",
|
||||
UserID: parseUUID(agentID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber (agent, after unsubscribe): %v", err)
|
||||
}
|
||||
if agentSubscribed {
|
||||
t.Fatal("expected agent to be unsubscribed in DB when X-Agent-ID is set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
1
server/migrations/046_agent_mcp_config.down.sql
Normal file
1
server/migrations/046_agent_mcp_config.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent DROP COLUMN IF EXISTS mcp_config;
|
||||
1
server/migrations/046_agent_mcp_config.up.sql
Normal file
1
server/migrations/046_agent_mcp_config.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent ADD COLUMN mcp_config jsonb;
|
||||
@@ -5,6 +5,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -25,8 +26,9 @@ type ExecOptions struct {
|
||||
SystemPrompt string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
ResumeSessionID string // if non-empty, resume a previous agent session
|
||||
CustomArgs []string // additional CLI arguments appended to the agent command
|
||||
ResumeSessionID string // if non-empty, resume a previous agent session
|
||||
CustomArgs []string // additional CLI arguments appended to the agent command
|
||||
McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config
|
||||
}
|
||||
|
||||
// Session represents a running agent execution.
|
||||
@@ -123,3 +125,28 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
||||
return detectCLIVersion(ctx, executablePath)
|
||||
}
|
||||
|
||||
// launchHeaders maps each supported agent type to the user-visible skeleton
|
||||
// that the daemon spawns before any custom_args are appended. This is
|
||||
// intentionally minimal — only the command + subcommand (or a short mode
|
||||
// label when there is no subcommand). Internal flags, transport values, and
|
||||
// environment variables are deliberately omitted so the string is a hint
|
||||
// about *what* users are extending, not a dump of the full command line.
|
||||
var launchHeaders = map[string]string{
|
||||
"claude": "claude (stream-json)",
|
||||
"codex": "codex app-server",
|
||||
"copilot": "copilot (json)",
|
||||
"cursor": "cursor-agent (stream-json)",
|
||||
"gemini": "gemini (stream-json)",
|
||||
"hermes": "hermes acp",
|
||||
"openclaw": "openclaw agent (json)",
|
||||
"opencode": "opencode run (json)",
|
||||
"pi": "pi (json mode)",
|
||||
}
|
||||
|
||||
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
|
||||
// empty string if the type is unknown. Callers render this as a preview so
|
||||
// users understand which command their custom_args get appended to.
|
||||
func LaunchHeader(agentType string) string {
|
||||
return launchHeaders[agentType]
|
||||
}
|
||||
|
||||
@@ -62,3 +62,28 @@ func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
||||
t.Fatal("expected error for missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The factory in New() enumerates every supported agent type; LaunchHeader
|
||||
// must stay in sync so the UI preview never shows an empty skeleton for a
|
||||
// runtime the daemon actually spawns. If a new backend is added, add an
|
||||
// entry to launchHeaders in agent.go and extend this list.
|
||||
supported := []string{
|
||||
"claude", "codex", "copilot", "cursor", "gemini",
|
||||
"hermes", "openclaw", "opencode", "pi",
|
||||
}
|
||||
for _, t_ := range supported {
|
||||
if header := LaunchHeader(t_); header == "" {
|
||||
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
if header := LaunchHeader("made-up-agent"); header != "" {
|
||||
t.Errorf("expected empty header for unknown type, got %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,28 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
|
||||
args := buildClaudeArgs(opts, b.cfg.Logger)
|
||||
|
||||
// If the caller provided an MCP config, write it to a temp file and pass
|
||||
// --mcp-config <path> so the agent uses a controlled set of MCP servers
|
||||
// instead of inheriting from the outer Claude Code session.
|
||||
var mcpConfigPath string
|
||||
var mcpFileCleanup func() // non-nil while this function owns the temp file
|
||||
if len(opts.McpConfig) > 0 {
|
||||
path, err := writeMcpConfigToTemp(opts.McpConfig)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
mcpConfigPath = path
|
||||
mcpFileCleanup = func() { os.Remove(mcpConfigPath) }
|
||||
args = append(args, "--mcp-config", mcpConfigPath)
|
||||
}
|
||||
// Clean up the temp file if we return before the goroutine takes ownership.
|
||||
defer func() {
|
||||
if mcpFileCleanup != nil {
|
||||
mcpFileCleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
@@ -77,6 +99,9 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
|
||||
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
|
||||
mcpFileCleanup = nil
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
@@ -84,6 +109,9 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
if mcpConfigPath != "" {
|
||||
defer os.Remove(mcpConfigPath)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var output strings.Builder
|
||||
@@ -161,12 +189,20 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
|
||||
b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed")
|
||||
if reportedSessionID != sessionID {
|
||||
b.cfg.Logger.Info("claude resume did not land; clearing fresh session id for daemon fallback",
|
||||
"requested_resume", opts.ResumeSessionID,
|
||||
"emitted_session", sessionID,
|
||||
)
|
||||
}
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: output.String(),
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: sessionID,
|
||||
SessionID: reportedSessionID,
|
||||
Usage: usage,
|
||||
}
|
||||
}()
|
||||
@@ -347,10 +383,11 @@ func trySend(ch chan<- Message, msg Message) {
|
||||
// overridden by user-configured custom_args. Overriding these would break
|
||||
// the daemon↔Claude communication protocol.
|
||||
var claudeBlockedArgs = map[string]blockedArgMode{
|
||||
"-p": blockedStandalone, // non-interactive mode
|
||||
"--output-format": blockedWithValue, // stream-json protocol
|
||||
"--input-format": blockedWithValue, // stream-json protocol
|
||||
"-p": blockedStandalone, // non-interactive mode
|
||||
"--output-format": blockedWithValue, // stream-json protocol
|
||||
"--input-format": blockedWithValue, // stream-json protocol
|
||||
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
|
||||
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
|
||||
}
|
||||
|
||||
func buildClaudeArgs(opts ExecOptions, logger *slog.Logger) []string {
|
||||
@@ -409,6 +446,20 @@ func buildClaudeInput(prompt string) ([]byte, error) {
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// resolveSessionID decides which session id to report on the Result. When the
|
||||
// caller requested --resume but claude emitted a fresh, different session id
|
||||
// AND the run failed, the resume did not land (claude prints
|
||||
// "No conversation found with session ID: ..." to stderr, generates a fresh
|
||||
// session, and exits). Returning "" in that case keeps the daemon's
|
||||
// retry-with-fresh-session fallback able to trigger, instead of silently
|
||||
// persisting a brand-new id as if resume had succeeded.
|
||||
func resolveSessionID(requestedResume, emitted string, failed bool) string {
|
||||
if failed && requestedResume != "" && emitted != "" && emitted != requestedResume {
|
||||
return ""
|
||||
}
|
||||
return emitted
|
||||
}
|
||||
|
||||
func buildEnv(extra map[string]string) []string {
|
||||
return mergeEnv(os.Environ(), extra)
|
||||
}
|
||||
@@ -438,7 +489,7 @@ func isFilteredChildEnvKey(key string) bool {
|
||||
type blockedArgMode int
|
||||
|
||||
const (
|
||||
blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value)
|
||||
blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value)
|
||||
blockedStandalone // flag is boolean, no value
|
||||
)
|
||||
|
||||
@@ -480,6 +531,25 @@ func filterCustomArgs(args []string, blocked map[string]blockedArgMode, logger *
|
||||
return filtered
|
||||
}
|
||||
|
||||
// writeMcpConfigToTemp writes raw MCP config JSON to a temporary file and returns
|
||||
// its path. The caller is responsible for removing the file when done.
|
||||
func writeMcpConfigToTemp(raw json.RawMessage) (string, error) {
|
||||
f, err := os.CreateTemp("", "multica-mcp-*.json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create mcp config temp file: %w", err)
|
||||
}
|
||||
if _, err := f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return "", fmt.Errorf("write mcp config temp file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", fmt.Errorf("close mcp config temp file: %w", err)
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, execPath, "--version")
|
||||
data, err := cmd.Output()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -221,9 +222,9 @@ func TestFilterCustomArgsBlocksProtocolFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
blocked := map[string]blockedArgMode{
|
||||
"--output-format": blockedWithValue,
|
||||
"--output-format": blockedWithValue,
|
||||
"--permission-mode": blockedWithValue,
|
||||
"-p": blockedStandalone,
|
||||
"-p": blockedStandalone,
|
||||
}
|
||||
logger := slog.Default()
|
||||
|
||||
@@ -409,6 +410,129 @@ func TestBuildEnvNilExtras(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgsBlocksMcpConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// --mcp-config is hardcoded by the daemon — it must not be overridable via custom_args.
|
||||
args := buildClaudeArgs(ExecOptions{
|
||||
CustomArgs: []string{"--mcp-config", "/tmp/evil.json", "--model", "o3"},
|
||||
}, slog.Default())
|
||||
|
||||
for i, a := range args {
|
||||
if a == "--mcp-config" {
|
||||
t.Fatalf("--mcp-config should be blocked from custom_args, found at index %d: %v", i, args)
|
||||
}
|
||||
if a == "/tmp/evil.json" {
|
||||
t.Fatalf("--mcp-config value should be consumed when blocking, but found it at index %d: %v", i, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocked args should still pass through.
|
||||
foundModel := false
|
||||
for i, a := range args {
|
||||
if a == "--model" && i+1 < len(args) && args[i+1] == "o3" {
|
||||
foundModel = true
|
||||
}
|
||||
}
|
||||
if !foundModel {
|
||||
t.Fatalf("expected --model o3 in args after blocking --mcp-config: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMcpConfigToTemp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := json.RawMessage(`{"mcpServers":{"test":{"command":"echo","args":["hello"]}}}`)
|
||||
path, err := writeMcpConfigToTemp(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("writeMcpConfigToTemp: %v", err)
|
||||
}
|
||||
|
||||
// File should exist and contain exactly the raw JSON.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp file %s: %v", path, err)
|
||||
}
|
||||
if !bytes.Equal(data, []byte(raw)) {
|
||||
t.Fatalf("expected %s, got %s", raw, data)
|
||||
}
|
||||
|
||||
// Cleanup should remove the file.
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove temp file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected temp file to be removed, but it still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requested string
|
||||
emitted string
|
||||
failed bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no resume requested propagates emitted",
|
||||
requested: "",
|
||||
emitted: "fresh-abc",
|
||||
failed: false,
|
||||
want: "fresh-abc",
|
||||
},
|
||||
{
|
||||
name: "resume succeeded keeps matching id",
|
||||
requested: "sess-old",
|
||||
emitted: "sess-old",
|
||||
failed: false,
|
||||
want: "sess-old",
|
||||
},
|
||||
{
|
||||
name: "resume succeeded but run failed mid-turn keeps id for later retry",
|
||||
requested: "sess-old",
|
||||
emitted: "sess-old",
|
||||
failed: true,
|
||||
want: "sess-old",
|
||||
},
|
||||
{
|
||||
name: "resume did not land and run failed clears id so daemon fallback fires",
|
||||
requested: "sess-dead",
|
||||
emitted: "fresh-new",
|
||||
failed: true,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "resume did not land but run succeeded keeps fresh id (defensive)",
|
||||
requested: "sess-dead",
|
||||
emitted: "fresh-new",
|
||||
failed: false,
|
||||
want: "fresh-new",
|
||||
},
|
||||
{
|
||||
name: "no emitted id leaves result empty",
|
||||
requested: "sess-old",
|
||||
emitted: "",
|
||||
failed: true,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := resolveSessionID(tc.requested, tc.emitted, tc.failed)
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveSessionID(%q, %q, %v) = %q, want %q",
|
||||
tc.requested, tc.emitted, tc.failed, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const archiveAgent = `-- name: ArchiveAgent :one
|
||||
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type ArchiveAgentParams struct {
|
||||
@@ -45,6 +45,41 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const clearAgentMcpConfig = `-- name: ClearAgentMcpConfig :one
|
||||
UPDATE agent SET mcp_config = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
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
|
||||
`
|
||||
|
||||
func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, clearAgentMcpConfig, id)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -218,9 +253,9 @@ const createAgent = `-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
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
|
||||
instructions, custom_env, custom_args, mcp_config
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
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
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
@@ -237,6 +272,7 @@ type CreateAgentParams struct {
|
||||
Instructions string `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
|
||||
@@ -254,6 +290,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
arg.Instructions,
|
||||
arg.CustomEnv,
|
||||
arg.CustomArgs,
|
||||
arg.McpConfig,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -276,6 +313,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -407,7 +445,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
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 FROM agent
|
||||
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 FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -434,12 +472,13 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
|
||||
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 FROM agent
|
||||
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 FROM agent
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -471,6 +510,7 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -671,7 +711,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :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 FROM agent
|
||||
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 FROM agent
|
||||
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -705,6 +745,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -717,7 +758,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
}
|
||||
|
||||
const listAllAgents = `-- name: ListAllAgents :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 FROM agent
|
||||
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 FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -751,6 +792,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -855,7 +897,7 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
||||
const restoreAgent = `-- name: RestoreAgent :one
|
||||
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
@@ -881,6 +923,7 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -932,9 +975,10 @@ UPDATE agent SET
|
||||
instructions = COALESCE($11, instructions),
|
||||
custom_env = COALESCE($12, custom_env),
|
||||
custom_args = COALESCE($13, custom_args),
|
||||
mcp_config = COALESCE($14, mcp_config),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
@@ -951,6 +995,7 @@ type UpdateAgentParams struct {
|
||||
Instructions pgtype.Text `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
|
||||
@@ -968,6 +1013,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
arg.Instructions,
|
||||
arg.CustomEnv,
|
||||
arg.CustomArgs,
|
||||
arg.McpConfig,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -990,6 +1036,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -997,7 +1044,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
@@ -1028,6 +1075,7 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type Agent struct {
|
||||
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
}
|
||||
|
||||
type AgentRuntime struct {
|
||||
|
||||
@@ -20,8 +20,8 @@ WHERE id = $1 AND workspace_id = $2;
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
instructions, custom_env, custom_args, mcp_config
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
@@ -38,10 +38,16 @@ UPDATE agent SET
|
||||
instructions = COALESCE(sqlc.narg('instructions'), instructions),
|
||||
custom_env = COALESCE(sqlc.narg('custom_env'), custom_env),
|
||||
custom_args = COALESCE(sqlc.narg('custom_args'), custom_args),
|
||||
mcp_config = COALESCE(sqlc.narg('mcp_config'), mcp_config),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearAgentMcpConfig :one
|
||||
UPDATE agent SET mcp_config = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ArchiveAgent :one
|
||||
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
|
||||
Reference in New Issue
Block a user