mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
80 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16f7be57c4 | ||
|
|
7f0c23a6ba | ||
|
|
e6767d2ba3 | ||
|
|
1ceb75e218 | ||
|
|
9138c05993 | ||
|
|
091ed7370a | ||
|
|
35557c0b11 | ||
|
|
03ad47200b | ||
|
|
93b754de53 | ||
|
|
609d2e06ae | ||
|
|
7c436c0dcb | ||
|
|
55ae78b902 | ||
|
|
cc00fda513 | ||
|
|
04e571b02f | ||
|
|
c62bd0ca12 | ||
|
|
51c7dbbeee | ||
|
|
46d745cb60 | ||
|
|
0a998d1cef | ||
|
|
a366984014 | ||
|
|
9ba9ea66f8 | ||
|
|
2be6fdae90 | ||
|
|
653c0adeee | ||
|
|
4458753102 | ||
|
|
3c0ed0f732 | ||
|
|
999d0728c5 | ||
|
|
b6a69c113e | ||
|
|
7995f7368f | ||
|
|
ed1a1dc6b1 | ||
|
|
97755ae45d | ||
|
|
7a896d3852 | ||
|
|
da63165cdc | ||
|
|
013584ef80 | ||
|
|
bb4944bae2 | ||
|
|
42e392c727 | ||
|
|
158a100779 | ||
|
|
e178682acd | ||
|
|
8779db976c | ||
|
|
eba68c15fd | ||
|
|
345cb984a9 | ||
|
|
f3355049bc | ||
|
|
dca86acc69 | ||
|
|
c71525e198 | ||
|
|
977dc6479d | ||
|
|
a97bd3da0b | ||
|
|
9dfe119f47 | ||
|
|
f2efd4b529 | ||
|
|
a1de20e971 | ||
|
|
27d0865f5f | ||
|
|
2cd6024851 | ||
|
|
5e74c411dc | ||
|
|
418049856f | ||
|
|
00042c0ec7 | ||
|
|
7c7d7feed3 | ||
|
|
6a451c1ce7 | ||
|
|
8c0708bb5d | ||
|
|
9170b01739 | ||
|
|
d37595b85e | ||
|
|
03310a581a | ||
|
|
fe0d450471 | ||
|
|
bc1185f525 | ||
|
|
0d95a7c7ef | ||
|
|
8587243ab6 | ||
|
|
740d8e773d | ||
|
|
9550e6c4e0 | ||
|
|
880c614039 | ||
|
|
f1f693afa5 | ||
|
|
c148288d5a | ||
|
|
ff5f6ac2ee | ||
|
|
a0d43ca31a | ||
|
|
a29ecfe02a | ||
|
|
8d3cb21c03 | ||
|
|
2b16cbb27a | ||
|
|
a757f3a8c4 | ||
|
|
56c38dc521 | ||
|
|
4bc9969765 | ||
|
|
a73a9d4036 | ||
|
|
4165401d16 | ||
|
|
e5881601ad | ||
|
|
77dbcaefad | ||
|
|
f99f50eb0c |
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -35,11 +35,13 @@ Closes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
|
||||
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -22,11 +21,17 @@ cp server/bin/multica /usr/local/bin/multica
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ which brew
|
||||
If `brew` is found, install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap && brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Then verify:
|
||||
@@ -51,6 +51,12 @@ multica version
|
||||
|
||||
If the version prints successfully, skip to **Step 3**.
|
||||
|
||||
To upgrade later, run:
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
|
||||
|
||||
If Homebrew is not available, download the binary directly.
|
||||
|
||||
@@ -37,8 +37,10 @@ RUN pnpm install --frozen-lockfile --offline
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
|
||||
14
README.md
14
README.md
@@ -50,13 +50,23 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
|
||||
|
||||
## Quick Install
|
||||
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
|
||||
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
|
||||
@@ -50,13 +50,23 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
|
||||
|
||||
## 快速安装
|
||||
|
||||
### macOS / Linux(推荐 Homebrew)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
|
||||
|
||||
### macOS / Linux(安装脚本)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
安装 Multica CLI,支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
|
||||
如果没有 Homebrew,可以使用安装脚本。脚本会安装 Multica CLI:检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
|
||||
|
||||
**Windows (PowerShell):**
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
|
||||
@@ -29,6 +29,12 @@ This clones the repository, starts all services via Docker Compose, installs the
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
|
||||
>
|
||||
> ```bash
|
||||
> brew install multica-ai/tap/multica
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
@@ -70,8 +76,7 @@ Each team member who wants to run AI agents locally needs to:
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI installed:
|
||||
|
||||
@@ -218,6 +218,26 @@ NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## LAN / Non-localhost Access
|
||||
|
||||
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
|
||||
|
||||
```bash
|
||||
# .env — replace with your server's LAN IP
|
||||
FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
@@ -32,4 +32,8 @@ win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
npmRebuild: false
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"electron-updater": "^6.8.3",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
@@ -114,6 +115,8 @@ if (!gotTheLock) {
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
if (mainWindow) {
|
||||
|
||||
46
apps/desktop/src/main/updater.ts
Normal file
46
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:download-progress", {
|
||||
percent: progress.percent,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:install", () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
9
apps/desktop/src/preload/index.d.ts
vendored
9
apps/desktop/src/preload/index.d.ts
vendored
@@ -7,10 +7,19 @@ interface DesktopAPI {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
updater: UpdaterAPI;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,35 @@ const desktopAPI = {
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
|
||||
ipcRenderer.on("updater:update-available", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-available", handler);
|
||||
},
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
|
||||
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
|
||||
contextBridge.exposeInMainWorld("updater", updaterAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.desktopAPI = desktopAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.updater = updaterAPI;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -51,6 +52,7 @@ export default function App() {
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,7 @@ description: Install the Multica CLI and start the agent daemon.
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -48,11 +47,17 @@ rm /tmp/multica.tar.gz
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -19,10 +19,28 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
|
||||
|
||||
Or install manually:
|
||||
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```bash
|
||||
# Install the CLI
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then configure, authenticate, and start the daemon:
|
||||
|
||||
```bash
|
||||
# Configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
@@ -33,6 +33,10 @@ 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`**.
|
||||
|
||||
<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`.
|
||||
</Callout>
|
||||
|
||||
<Callout>
|
||||
For a step-by-step setup, see below.
|
||||
</Callout>
|
||||
@@ -73,8 +77,7 @@ The daemon runs on your local machine (not inside Docker). It detects installed
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI:
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
onboardingPath="/onboarding"
|
||||
loginPath="/login"
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -22,12 +22,22 @@ function hasLegacyToken(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Derive WebSocket URL from the page origin so self-hosted / LAN deployments
|
||||
// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule
|
||||
// (/ws → backend) handles proxying.
|
||||
function deriveWsUrl(): string | undefined {
|
||||
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
wsUrl={deriveWsUrl()}
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
|
||||
@@ -277,6 +277,32 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI & Agent Env Vars",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI as a new agent runtime with live log streaming",
|
||||
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
|
||||
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
|
||||
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
|
||||
"Sub-issues inherit parent project automatically",
|
||||
],
|
||||
improvements: [
|
||||
"Editor bubble menu and link preview rewritten for reliability",
|
||||
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
|
||||
"Self-hosted WebSocket URL auto-derived for LAN access",
|
||||
],
|
||||
fixes: [
|
||||
"S3 upload keys scoped by workspace (security)",
|
||||
"Workspace membership validation for subscriptions and uploads (security)",
|
||||
"Active tasks auto-cancelled when issue status changes to cancelled",
|
||||
"Agent task stall when process hangs on stdout",
|
||||
"Daemon trigger prompt now embeds the actual triggering comment content",
|
||||
"Login and dashboard redirect stability improvements",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
|
||||
@@ -277,6 +277,32 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI 与 Agent 环境变量",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
|
||||
"Agent 自定义环境变量(router/proxy 模式),新增专用设置标签页",
|
||||
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
|
||||
"CLI `--parent` 更新父 Issue,`--content-stdin` 管道输入评论内容",
|
||||
"子 Issue 自动继承父级项目",
|
||||
],
|
||||
improvements: [
|
||||
"编辑器气泡菜单和链接预览重写",
|
||||
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析)",
|
||||
"自部署 WebSocket URL 自动适配局域网访问",
|
||||
],
|
||||
fixes: [
|
||||
"S3 上传路径按工作区隔离(安全)",
|
||||
"订阅和上传新增工作区成员身份校验(安全)",
|
||||
"Issue 状态改为已取消时自动终止进行中的任务",
|
||||
"Agent 进程 stdout 挂起导致任务卡住",
|
||||
"Daemon 触发提示现在嵌入实际的触发评论内容",
|
||||
"登录和仪表盘跳转稳定性改进",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const loggedIn = request.cookies.has("multica_logged_in");
|
||||
if (loggedIn) {
|
||||
return NextResponse.redirect(new URL("/issues", request.url));
|
||||
}
|
||||
export function proxy(_request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const mockAgents: Agent[] = [
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
|
||||
@@ -62,6 +62,7 @@ services:
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
@@ -198,6 +198,10 @@ export class ApiClient {
|
||||
await this.fetch("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
async issueCliToken(): Promise<{ token: string }> {
|
||||
return this.fetch("/api/cli-token", { method: "POST" });
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
@@ -168,12 +168,21 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// Invalidate old parent's children cache
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
// Invalidate new parent's children cache when parent_issue_id changed
|
||||
const newParentId = vars.parent_issue_id;
|
||||
if (newParentId && newParentId !== ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, newParentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,16 +29,19 @@ export function onIssueUpdated(
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
// Look up the parent before mutating list state, so we can also keep the
|
||||
// parent's children cache in sync (powers the sub-issues list shown on
|
||||
// the parent issue page).
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const parentId =
|
||||
issue.parent_issue_id ??
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
@@ -63,10 +66,22 @@ export function onIssueUpdated(
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
|
||||
// Invalidate old parent's children (issue was removed from it)
|
||||
if (oldParentId) {
|
||||
if (parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
|
||||
} else {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Invalidate new parent's children (issue was added to it)
|
||||
if (newParentId && parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
|
||||
}
|
||||
if (oldParentId || newParentId) {
|
||||
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface Agent {
|
||||
avatar_url: string | null;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
custom_env: Record<string, string>;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -65,6 +66,7 @@ export interface CreateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export interface UpdateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
@@ -34,17 +35,19 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -158,6 +161,12 @@ export function AgentDetail({
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
agent={agent}
|
||||
|
||||
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
interface EnvEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
|
||||
return Object.entries(env).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
visible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) {
|
||||
map[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function EnvTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
envMapToEntries(agent.custom_env ?? {}),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentEnvMap = entriesToEnvMap(envEntries);
|
||||
const originalEnvMap = agent.custom_env ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setEnvEntries([
|
||||
...envEntries,
|
||||
{ id: nextEnvId++, key: "", value: "", visible: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEnvEntry = (index: number) => {
|
||||
setEnvEntries(envEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEnvEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
val: string,
|
||||
) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnvVisibility = (index: number) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, visible: !entry.visible } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size < keys.length) {
|
||||
toast.error("Duplicate environment variable keys");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ custom_env: currentEnvMap });
|
||||
toast.success("Environment variables saved");
|
||||
} catch {
|
||||
toast.error("Failed to save environment variables");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_BASE_URL)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="w-[40%] font-mono text-xs"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={entry.visible ? "text" : "password"}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateEnvEntry(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="value"
|
||||
className="pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnvVisibility(index)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvEntry(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function SettingsTab({
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
|
||||
@@ -13,6 +13,7 @@ const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
|
||||
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
|
||||
const mockApiSetToken = vi.hoisted(() => vi.fn());
|
||||
const mockApiGetMe = vi.hoisted(() => vi.fn());
|
||||
const mockApiIssueCliToken = vi.hoisted(() => vi.fn());
|
||||
const mockSetQueryData = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
@@ -58,6 +59,7 @@ vi.mock("@multica/core/api", () => ({
|
||||
verifyCode: mockApiVerifyCode,
|
||||
setToken: mockApiSetToken,
|
||||
getMe: mockApiGetMe,
|
||||
issueCliToken: mockApiIssueCliToken,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -88,7 +90,8 @@ describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.clearAllMocks();
|
||||
// Default: no existing session
|
||||
// Default: no existing session (getMe rejects when no auth)
|
||||
mockApiGetMe.mockRejectedValue(new Error("unauthorized"));
|
||||
localStorage.clear();
|
||||
// Reset window.location for tests that change it
|
||||
Object.defineProperty(window, "location", {
|
||||
@@ -301,7 +304,7 @@ describe("LoginPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// After transitioning to code step, cooldown is 10s
|
||||
// After transitioning to code step, cooldown is 60s
|
||||
const resendBtn = screen.getByRole("button", { name: /resend in/i });
|
||||
expect(resendBtn).toBeDisabled();
|
||||
});
|
||||
@@ -337,9 +340,9 @@ describe("LoginPage", () => {
|
||||
// sendCode was called once for the initial send
|
||||
expect(mockSendCode).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past the 10s cooldown one second at a time so React can
|
||||
// Advance past the 60s cooldown one second at a time so React can
|
||||
// process each setCooldown state update between ticks.
|
||||
for (let i = 0; i < 11; i++) {
|
||||
for (let i = 0; i < 61; i++) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
@@ -385,11 +388,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("shows cli_confirm step when existing session + cliCallback", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails first, then localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
@@ -414,11 +420,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("CLI authorize button redirects to callback URL", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails, localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
const onTokenObtained = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -446,11 +455,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("'Use a different account' returns to email step", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails, localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
@@ -475,6 +487,65 @@ describe("LoginPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLI callback — cookie-based session (no localStorage token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("detects cookie-based session and shows cli_confirm when no localStorage token", async () => {
|
||||
// No localStorage token — getMe succeeds via HttpOnly cookie
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "cookie@example.com",
|
||||
name: "Cookie User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
onSuccess={onSuccess}
|
||||
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/cookie@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("CLI authorize with cookie session calls issueCliToken and redirects", async () => {
|
||||
// No localStorage token — getMe succeeds via cookie
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "cookie@example.com",
|
||||
name: "Cookie User",
|
||||
});
|
||||
mockApiIssueCliToken.mockResolvedValueOnce({ token: "fresh-jwt" });
|
||||
const onTokenObtained = vi.fn();
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
onSuccess={onSuccess}
|
||||
onTokenObtained={onTokenObtained}
|
||||
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /^authorize$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiIssueCliToken).toHaveBeenCalled();
|
||||
expect(onTokenObtained).toHaveBeenCalled();
|
||||
expect(window.location.href).toContain(
|
||||
"http://localhost:9876/callback?token=fresh-jwt&state=abc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLI callback — code verification redirects
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -655,12 +726,34 @@ describe("validateCliCallback", () => {
|
||||
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 10.x.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 172.16-31.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 172.x outside 16-31 range", () => {
|
||||
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts 192.168.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects https:// URLs", () => {
|
||||
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-localhost hosts", () => {
|
||||
it("rejects public IPs and domains", () => {
|
||||
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid URLs", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
@@ -68,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
|
||||
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
|
||||
}
|
||||
|
||||
/** Validate that a CLI callback URL points to localhost over HTTP. */
|
||||
/**
|
||||
* Validate that a CLI callback URL points to a safe host over HTTP.
|
||||
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
|
||||
* on local VMs while blocking arbitrary public hosts.
|
||||
*/
|
||||
export function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
const cbUrl = new URL(cliCallback);
|
||||
if (cbUrl.protocol !== "http:") return false;
|
||||
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
|
||||
return false;
|
||||
return true;
|
||||
const h = cbUrl.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return true;
|
||||
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
|
||||
if (/^10\./.test(h)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
||||
if (/^192\.168\./.test(h)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -102,23 +110,43 @@ export function LoginPage({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [existingUser, setExistingUser] = useState<User | null>(null);
|
||||
// Tracks how the existing session was detected so handleCliAuthorize
|
||||
// uses the matching token source (cookie → issueCliToken, localStorage → direct).
|
||||
const authSourceRef = useRef<"cookie" | "localStorage">("cookie");
|
||||
|
||||
// Check for existing session when CLI callback is present
|
||||
// Check for existing session when CLI callback is present.
|
||||
// Prioritises cookie auth (= current browser session) to avoid authorising
|
||||
// the CLI with a stale or mismatched localStorage token.
|
||||
useEffect(() => {
|
||||
if (!cliCallback) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
api.setToken(token);
|
||||
// Ensure no stale bearer token interferes — we want to test the cookie first.
|
||||
api.setToken(null);
|
||||
|
||||
api
|
||||
.getMe()
|
||||
.then((user) => {
|
||||
authSourceRef.current = "cookie";
|
||||
setExistingUser(user);
|
||||
setStep("cli_confirm");
|
||||
})
|
||||
.catch(() => {
|
||||
api.setToken(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
// Cookie auth failed — fall back to localStorage token
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
api.setToken(token);
|
||||
api
|
||||
.getMe()
|
||||
.then((user) => {
|
||||
authSourceRef.current = "localStorage";
|
||||
setExistingUser(user);
|
||||
setStep("cli_confirm");
|
||||
})
|
||||
.catch(() => {
|
||||
api.setToken(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
});
|
||||
});
|
||||
}, [cliCallback]);
|
||||
|
||||
@@ -142,7 +170,7 @@ export function LoginPage({
|
||||
await useAuthStore.getState().sendCode(email);
|
||||
setStep("code");
|
||||
setCode("");
|
||||
setCooldown(10);
|
||||
setCooldown(60);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -195,7 +223,7 @@ export function LoginPage({
|
||||
setError("");
|
||||
try {
|
||||
await useAuthStore.getState().sendCode(email);
|
||||
setCooldown(10);
|
||||
setCooldown(60);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to resend code",
|
||||
@@ -203,13 +231,32 @@ export function LoginPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCliAuthorize = () => {
|
||||
const handleCliAuthorize = async () => {
|
||||
if (!cliCallback) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
onTokenObtained?.();
|
||||
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
|
||||
|
||||
try {
|
||||
let token: string;
|
||||
|
||||
if (authSourceRef.current === "localStorage") {
|
||||
// Session was detected via localStorage — reuse that token directly.
|
||||
const stored = localStorage.getItem("multica_token");
|
||||
if (!stored) throw new Error("token missing");
|
||||
token = stored;
|
||||
} else {
|
||||
// Session was detected via cookie — obtain a bearer token from the server.
|
||||
const res = await api.issueCliToken();
|
||||
token = res.token;
|
||||
}
|
||||
|
||||
onTokenObtained?.();
|
||||
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
|
||||
} catch {
|
||||
setError("Failed to authorize CLI. Please log in again.");
|
||||
setExistingUser(null);
|
||||
setStep("email");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EditorBubbleMenu — floating formatting toolbar for text selection.
|
||||
*
|
||||
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
|
||||
* focus management (preventHide flag, relatedTarget checks, mousedown
|
||||
* capture). We only add scroll-container visibility detection on top,
|
||||
* because the plugin's hide middleware can't detect nested scroll
|
||||
* container clipping (virtual element has no contextElement).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
@@ -15,11 +25,10 @@ import {
|
||||
TooltipProvider,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
@@ -45,20 +54,9 @@ import {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Force re-render when editor state changes so isActive() returns fresh values */
|
||||
function useEditorTransactionUpdate(editor: Editor) {
|
||||
const [, setState] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => setState((n) => n + 1);
|
||||
editor.on("transaction", handler);
|
||||
return () => {
|
||||
editor.off("transaction", handler);
|
||||
};
|
||||
}, [editor]);
|
||||
}
|
||||
|
||||
function shouldShowBubbleMenu({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from,
|
||||
to,
|
||||
@@ -72,26 +70,28 @@ function shouldShowBubbleMenu({
|
||||
}) {
|
||||
if (!editor.isEditable) return false;
|
||||
if (state.selection.empty) return false;
|
||||
if (!state.doc.textBetween(from, to).length) return false;
|
||||
if (!state.doc.textBetween(from, to).trim().length) return false;
|
||||
if (state.selection instanceof NodeSelection) return false;
|
||||
if (!view.hasFocus()) return false;
|
||||
const $from = state.doc.resolve(from);
|
||||
if ($from.parent.type.name === "codeBlock") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Detect macOS for keyboard shortcut labels */
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
const mod = isMac ? "\u2318" : "Ctrl";
|
||||
|
||||
/** Hoisted to avoid new reference on every render (triggers plugin updateOptions) */
|
||||
const BUBBLE_MENU_OPTIONS = {
|
||||
strategy: "fixed" as const,
|
||||
placement: "top" as const,
|
||||
offset: 8,
|
||||
flip: true,
|
||||
shift: { padding: 8 },
|
||||
};
|
||||
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
|
||||
function getScrollParent(el: HTMLElement): HTMLElement | Window {
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark Toggle Button
|
||||
@@ -141,6 +141,36 @@ function MarkButton({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Protocols that can execute code in the browser — the only ones we block. */
|
||||
const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i;
|
||||
const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Normalise a user-entered URL: add protocol, detect mailto, block XSS.
|
||||
*
|
||||
* Uses a blocklist (not allowlist) for protocols — only `javascript:`,
|
||||
* `data:`, and `vbscript:` are blocked. All other protocols pass through
|
||||
* because they can't execute code in the browser and are legitimate
|
||||
* deep-link targets in a team tool (slack://, vscode://, figma://).
|
||||
* Tiptap's `isAllowedUri` in the `setLink` command provides a second
|
||||
* safety layer.
|
||||
*/
|
||||
function normalizeUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("/")) return trimmed;
|
||||
if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return "";
|
||||
if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed;
|
||||
if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`;
|
||||
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link Edit Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -157,25 +187,16 @@ function LinkEditBar({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// autoFocus workaround — setTimeout to ensure the input is mounted
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
let href = url.trim();
|
||||
const href = normalizeUrl(url);
|
||||
if (!href) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
} else {
|
||||
if (!/^https?:\/\//.test(href) && !href.startsWith("/")) {
|
||||
href = `https://${href}`;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href })
|
||||
.run();
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||
}
|
||||
onClose();
|
||||
}, [editor, url, onClose]);
|
||||
@@ -186,10 +207,7 @@ function LinkEditBar({
|
||||
}, [editor, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bubble-menu-link-edit"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
@@ -198,44 +216,19 @@ function LinkEditBar({
|
||||
aria-label="URL"
|
||||
className="h-7 flex-1 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}
|
||||
if (e.key === "Enter") { e.preventDefault(); apply(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); onClose(); editor.commands.focus(); }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={apply}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={apply} onMouseDown={(e) => e.preventDefault()}>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
{existingHref && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={remove}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={remove} onMouseDown={(e) => e.preventDefault()}>
|
||||
<Unlink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -246,74 +239,56 @@ function LinkEditBar({
|
||||
// Heading Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HeadingDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const activeLevel = [1, 2, 3].find((l) =>
|
||||
editor.isActive("heading", { level: l }),
|
||||
);
|
||||
|
||||
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
|
||||
const label = activeLevel ? `H${activeLevel}` : "Text";
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: "Normal Text",
|
||||
icon: Type,
|
||||
active: !activeLevel,
|
||||
action: () => editor.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 1",
|
||||
icon: Heading1,
|
||||
active: activeLevel === 1,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 2",
|
||||
icon: Heading2,
|
||||
active: activeLevel === 2,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 3",
|
||||
icon: Heading3,
|
||||
active: activeLevel === 3,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
|
||||
{ label: "Heading 1", icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ label: "Heading 2", icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ label: "Heading 3", icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
];
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange(next);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className="size-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
className="w-auto"
|
||||
className="w-auto min-w-32 p-1"
|
||||
initialFocus={false}
|
||||
finalFocus={false}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={item.action}
|
||||
className="gap-2 text-xs"
|
||||
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<item.icon className="size-3.5" />
|
||||
{item.label}
|
||||
{item.active && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,223 +296,173 @@ function HeadingDropdown({
|
||||
// List Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isBullet = editor.isActive("bulletList");
|
||||
const isOrdered = editor.isActive("orderedList");
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange(next);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted"
|
||||
aria-pressed={isBullet || isOrdered}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<List className="size-3.5" />
|
||||
<ChevronDown className="size-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
List
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={8}>List</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
className="w-auto"
|
||||
className="w-auto min-w-32 p-1"
|
||||
initialFocus={false}
|
||||
finalFocus={false}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className="gap-2 text-xs"
|
||||
<button
|
||||
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
Bullet List
|
||||
<List className="size-3.5" /> Bullet List
|
||||
{isBullet && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className="gap-2 text-xs"
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
Ordered List
|
||||
<ListOrdered className="size-3.5" /> Ordered List
|
||||
{isOrdered && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Bubble Menu
|
||||
// Main Bubble Menu — native Tiptap <BubbleMenu>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const [focused, setFocused] = useState(editor.view.hasFocus());
|
||||
const modeRef = useRef(mode);
|
||||
modeRef.current = mode;
|
||||
// Track whether a child dropdown is open — blur during dropdown interaction should not hide
|
||||
const menuOpenRef = useRef(false);
|
||||
const handleMenuOpenChange = useCallback((open: boolean) => {
|
||||
menuOpenRef.current = open;
|
||||
}, []);
|
||||
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
|
||||
|
||||
useEditorTransactionUpdate(editor);
|
||||
|
||||
// Hide bubble menu when editor loses focus (but not when a child dropdown is open)
|
||||
// Find the real scroll container once on mount
|
||||
useEffect(() => {
|
||||
const onFocus = () => setFocused(true);
|
||||
const onBlur = () => {
|
||||
setTimeout(() => {
|
||||
if (!editor.isDestroyed && !editor.view.hasFocus() && !menuOpenRef.current) {
|
||||
setFocused(false);
|
||||
setScrollTarget(getScrollParent(editor.view.dom));
|
||||
}, [editor]);
|
||||
|
||||
// Hide when the selection scrolls outside the scroll container's
|
||||
// visible area. The plugin's hide middleware can't detect this because
|
||||
// its virtual reference element has no contextElement — Floating UI
|
||||
// only checks viewport bounds. We use `display` (not managed by the
|
||||
// plugin) as an additive visibility layer.
|
||||
const scrollHiddenRef = useRef(false);
|
||||
const [, forceRender] = useState(0);
|
||||
useEffect(() => {
|
||||
if (scrollTarget === window) return;
|
||||
const el = scrollTarget as HTMLElement;
|
||||
|
||||
const onScroll = () => {
|
||||
if (editor.state.selection.empty) {
|
||||
if (scrollHiddenRef.current) {
|
||||
scrollHiddenRef.current = false;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
editor.on("focus", onFocus);
|
||||
editor.on("blur", onBlur);
|
||||
return () => {
|
||||
editor.off("focus", onFocus);
|
||||
editor.off("blur", onBlur);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Reset to toolbar mode when selection changes — but not during link editing.
|
||||
// Also restore focused state (scroll sets it to false, new selection should bring it back).
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (modeRef.current !== "link-edit") setMode("toolbar");
|
||||
if (editor.view.hasFocus()) setFocused(true);
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Hide when an ancestor of the editor scrolls (capture phase catches non-bubbling scroll events).
|
||||
// Scoped to ancestors only — dropdown/sidebar scrolls won't trigger this.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const target = e.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.contains(editor.view.dom)
|
||||
) {
|
||||
setFocused(false);
|
||||
return;
|
||||
}
|
||||
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
|
||||
if (scrollHiddenRef.current !== !visible) {
|
||||
scrollHiddenRef.current = !visible;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("scroll", handler, true);
|
||||
return () => document.removeEventListener("scroll", handler, true);
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [editor, scrollTarget]);
|
||||
|
||||
// Reset scroll-hidden and mode when selection changes
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setMode("toolbar");
|
||||
if (scrollHiddenRef.current) {
|
||||
scrollHiddenRef.current = false;
|
||||
forceRender((n) => n + 1);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => { editor.off("selectionUpdate", handler); };
|
||||
}, [editor]);
|
||||
|
||||
const openLinkEdit = useCallback(() => {
|
||||
setMode("link-edit");
|
||||
}, []);
|
||||
|
||||
const closeLinkEdit = useCallback(() => {
|
||||
setMode("toolbar");
|
||||
}, []);
|
||||
|
||||
if (!focused) return null;
|
||||
// Refocus editor when Base UI dropdown closes
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(open: boolean) => { if (!open) editor.commands.focus(); },
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={shouldShowBubbleMenu}
|
||||
updateDelay={0}
|
||||
style={{ zIndex: 50 }}
|
||||
options={BUBBLE_MENU_OPTIONS}
|
||||
style={{
|
||||
zIndex: 50,
|
||||
display: scrollHiddenRef.current ? "none" : undefined,
|
||||
}}
|
||||
options={{
|
||||
strategy: "fixed",
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: true,
|
||||
shift: { padding: 8 },
|
||||
hide: true,
|
||||
scrollTarget,
|
||||
}}
|
||||
>
|
||||
{mode === "link-edit" ? (
|
||||
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
|
||||
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
|
||||
) : (
|
||||
<TooltipProvider delay={300}>
|
||||
<div className="bubble-menu">
|
||||
{/* Group 1: Inline Marks */}
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="bold"
|
||||
icon={Bold}
|
||||
label="Bold"
|
||||
shortcut={`${mod}+B`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="italic"
|
||||
icon={Italic}
|
||||
label="Italic"
|
||||
shortcut={`${mod}+I`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="strike"
|
||||
icon={Strikethrough}
|
||||
label="Strikethrough"
|
||||
shortcut={`${mod}+Shift+S`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="code"
|
||||
icon={Code}
|
||||
label="Code"
|
||||
shortcut={`${mod}+E`}
|
||||
/>
|
||||
|
||||
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
|
||||
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
|
||||
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
|
||||
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
{/* Group 2: Link */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("link")}
|
||||
onPressedChange={openLinkEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Link2 className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Link
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
{/* Group 3: Block Transforms */}
|
||||
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Quote className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Quote
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
@@ -486,4 +487,22 @@
|
||||
0 4px 12px color-mix(in srgb, black 12%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, black 4%, transparent);
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Link hover card — shows URL + actions on link hover */
|
||||
.link-hover-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.25rem 0.25rem 0.5rem;
|
||||
background: var(--popover);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow:
|
||||
0 4px 12px color-mix(in srgb, black 12%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, black 4%, transparent);
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
76
packages/views/editor/content-editor.test.tsx
Normal file
76
packages/views/editor/content-editor.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const mockFocus = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("./extensions", () => ({
|
||||
createEditorExtensions: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./extensions/file-upload", () => ({
|
||||
uploadAndInsertFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/preprocess", () => ({
|
||||
preprocessMarkdown: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("./bubble-menu", () => ({
|
||||
EditorBubbleMenu: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditor: () => ({
|
||||
commands: {
|
||||
focus: mockFocus,
|
||||
clearContent: vi.fn(),
|
||||
},
|
||||
getMarkdown: () => "",
|
||||
state: {
|
||||
doc: {
|
||||
content: {
|
||||
size: 0,
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
empty: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
EditorContent: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="editor-content">
|
||||
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ContentEditor } from "./content-editor";
|
||||
|
||||
describe("ContentEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses the editor when clicking the empty container area", () => {
|
||||
render(<ContentEditor placeholder="Add description..." />);
|
||||
|
||||
const shell = screen.getByTestId("editor-content").parentElement;
|
||||
expect(shell).not.toBeNull();
|
||||
|
||||
fireEvent.mouseDown(shell!);
|
||||
|
||||
expect(mockFocus).toHaveBeenCalledWith("end");
|
||||
});
|
||||
|
||||
it("does not hijack clicks that land inside the ProseMirror node", () => {
|
||||
render(<ContentEditor placeholder="Add description..." />);
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
|
||||
|
||||
expect(mockFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -39,8 +40,21 @@ import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import "./content-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
|
||||
* from serialised markdown so they never reach the database. */
|
||||
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
|
||||
|
||||
function stripBlobUrls(md: string): string {
|
||||
return md.replace(BLOB_IMAGE_RE, "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,6 +76,8 @@ interface ContentEditorRef {
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
uploadFile: (file: File) => void;
|
||||
/** True when file uploads are still in progress. */
|
||||
hasActiveUploads: () => boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,7 +130,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
@@ -131,33 +147,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
const href = link?.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return false;
|
||||
|
||||
const openLink = () => {
|
||||
if (href.startsWith("/")) {
|
||||
// Internal path — dispatch custom event so the app can handle it
|
||||
// (direct window.open breaks in Electron hash router)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
if (!editable) {
|
||||
// Readonly: any click on link opens new tab
|
||||
event.preventDefault();
|
||||
openLink();
|
||||
return true;
|
||||
// Open the link. Internal paths use multica:navigate
|
||||
// (Electron hash-router safe), external open in new tab.
|
||||
event.preventDefault();
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// Edit mode: Cmd/Ctrl+click opens link
|
||||
event.preventDefault();
|
||||
openLink();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
@@ -192,7 +192,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
}, [editor, editable, defaultValue]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
@@ -204,14 +204,44 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
const endPos = editor.state.doc.content.size;
|
||||
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
||||
},
|
||||
hasActiveUploads: () => {
|
||||
if (!editor) return false;
|
||||
let uploading = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.attrs.uploading) uploading = true;
|
||||
return !uploading;
|
||||
});
|
||||
return uploading;
|
||||
},
|
||||
}));
|
||||
|
||||
// Link hover card — disabled when BubbleMenu is active (has selection)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const hoverDisabled = !editor?.state.selection.empty;
|
||||
const hover = useLinkHover(wrapperRef, hoverDisabled);
|
||||
|
||||
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!editable || !editor) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".ProseMirror")) return;
|
||||
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
|
||||
|
||||
event.preventDefault();
|
||||
editor.commands.focus("end");
|
||||
};
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full">
|
||||
<EditorContent editor={editor} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex min-h-full flex-col"
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && <EditorBubbleMenu editor={editor} />}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -47,9 +47,10 @@ import { ImageView } from "./image-view";
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const LinkEditable = Link.extend({ inclusive: false }).configure({
|
||||
openOnClick: true,
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: false,
|
||||
linkOnPaste: true,
|
||||
defaultProtocol: "https",
|
||||
});
|
||||
|
||||
const LinkReadonly = Link.configure({
|
||||
@@ -103,6 +104,9 @@ export function createEditorExtensions(
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
// ⚠️ Link MUST appear before markdownPaste in this array.
|
||||
// linkOnPaste relies on Link's handlePaste plugin firing first;
|
||||
// markdownPaste's handlePaste is a catch-all that returns true.
|
||||
editable ? LinkEditable : LinkReadonly,
|
||||
ImageExtension,
|
||||
Table.configure({ resizable: false }),
|
||||
|
||||
@@ -40,6 +40,9 @@ export function createMarkdownPasteExtension() {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return false;
|
||||
|
||||
// If clipboard has files, defer to the fileUpload extension.
|
||||
if (clipboard.files?.length) return false;
|
||||
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
@@ -54,7 +54,14 @@ function IssueMention({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
const listIssue = issues.find((i) => i.id === issueId);
|
||||
|
||||
const { data: detailIssue } = useQuery({
|
||||
...issueDetailOptions(wsId, issueId),
|
||||
enabled: !listIssue,
|
||||
});
|
||||
|
||||
const issue = listIssue ?? detailIssue;
|
||||
|
||||
const issuePath = `/issues/${issueId}`;
|
||||
const tabTitle = issue ? `${issue.identifier}: ${issue.title}` : undefined;
|
||||
|
||||
236
packages/views/editor/link-hover-card.tsx
Normal file
236
packages/views/editor/link-hover-card.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LinkHoverCard — floating card shown on link hover.
|
||||
*
|
||||
* Displays the URL with Copy and Open actions. Portaled to body
|
||||
* with position:fixed to escape overflow:hidden containers.
|
||||
* Shows after 300ms hover delay, hides after 150ms mouse-out
|
||||
* (cancelled if mouse enters the card).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { ExternalLink, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openLink(href: string) {
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateUrl(url: string, max = 48): string {
|
||||
if (url.length <= max) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const origin = u.origin;
|
||||
const rest = url.slice(origin.length);
|
||||
if (rest.length <= 10) return url;
|
||||
return `${origin}${rest.slice(0, max - origin.length - 1)}…`;
|
||||
} catch {
|
||||
return `${url.slice(0, max - 1)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook — manages hover state with enter/leave delays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SHOW_DELAY = 300;
|
||||
const HIDE_DELAY = 150;
|
||||
|
||||
interface HoverState {
|
||||
visible: boolean;
|
||||
href: string;
|
||||
anchorEl: HTMLAnchorElement | null;
|
||||
}
|
||||
|
||||
function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disabled?: boolean) {
|
||||
const [state, setState] = useState<HoverState>({ visible: false, href: "", anchorEl: null });
|
||||
const showTimer = useRef(0);
|
||||
const hideTimer = useRef(0);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
clearTimeout(showTimer.current);
|
||||
clearTimeout(hideTimer.current);
|
||||
}, []);
|
||||
|
||||
// Container mouse events — detect <a> hover
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || disabled) return;
|
||||
|
||||
const onMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const link = target.closest("a") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return;
|
||||
|
||||
clearTimeout(hideTimer.current);
|
||||
showTimer.current = window.setTimeout(() => {
|
||||
setState({ visible: true, href, anchorEl: link });
|
||||
}, SHOW_DELAY);
|
||||
};
|
||||
|
||||
const onMouseOut = (e: MouseEvent) => {
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
// Don't hide if mouse moved to the hover card
|
||||
if (related && cardRef.current?.contains(related)) return;
|
||||
// Don't hide if mouse moved to another part of the same link
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (link && link.contains(related)) return;
|
||||
|
||||
clearTimeout(showTimer.current);
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
setState((s) => ({ ...s, visible: false }));
|
||||
}, HIDE_DELAY);
|
||||
};
|
||||
|
||||
container.addEventListener("mouseover", onMouseOver);
|
||||
container.addEventListener("mouseout", onMouseOut);
|
||||
return () => {
|
||||
container.removeEventListener("mouseover", onMouseOver);
|
||||
container.removeEventListener("mouseout", onMouseOut);
|
||||
clearTimers();
|
||||
};
|
||||
}, [containerRef, disabled, clearTimers]);
|
||||
|
||||
// Card mouse events — keep visible while hovering the card
|
||||
const onCardEnter = useCallback(() => {
|
||||
clearTimeout(hideTimer.current);
|
||||
}, []);
|
||||
|
||||
const onCardLeave = useCallback(() => {
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
setState((s) => ({ ...s, visible: false }));
|
||||
}, HIDE_DELAY);
|
||||
}, []);
|
||||
|
||||
return { ...state, cardRef, onCardEnter, onCardLeave };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LinkHoverCard({
|
||||
visible,
|
||||
href,
|
||||
anchorEl,
|
||||
cardRef,
|
||||
onCardEnter,
|
||||
onCardLeave,
|
||||
}: {
|
||||
visible: boolean;
|
||||
href: string;
|
||||
anchorEl: HTMLAnchorElement | null;
|
||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||
onCardEnter: () => void;
|
||||
onCardLeave: () => void;
|
||||
}) {
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
const [positioned, setPositioned] = useState(false);
|
||||
|
||||
// Position the card when the portal div is mounted (ref callback).
|
||||
// Using useEffect would race with portal rendering — the div might
|
||||
// not be in the DOM yet when the effect runs.
|
||||
const setCardRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (!node || !anchorEl) {
|
||||
setPositioned(false);
|
||||
return;
|
||||
}
|
||||
computePosition(anchorEl, node, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
setPos({ top: y, left: x });
|
||||
setPositioned(true);
|
||||
});
|
||||
},
|
||||
[anchorEl, cardRef],
|
||||
);
|
||||
|
||||
// Reset positioned when hidden
|
||||
useEffect(() => {
|
||||
if (!visible) setPositioned(false);
|
||||
}, [visible]);
|
||||
|
||||
if (!visible || !anchorEl) return null;
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
toast.success("Link copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={setCardRef}
|
||||
className="link-hover-card"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
zIndex: 50,
|
||||
display: positioned ? undefined : "none",
|
||||
}}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
|
||||
title={href}
|
||||
>
|
||||
{truncateUrl(href)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleCopy}
|
||||
title="Copy link"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleOpen}
|
||||
title="Open link"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export { useLinkHover, LinkHoverCard };
|
||||
@@ -16,7 +16,7 @@
|
||||
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
@@ -33,6 +33,7 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
||||
import { ImageLightbox } from "./extensions/image-view";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -109,7 +110,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
|
||||
}
|
||||
|
||||
const components: Partial<Components> = {
|
||||
// Links — route mention:// to mention components, others open in new tab
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("mention://")) {
|
||||
const match = href.match(
|
||||
@@ -128,13 +129,20 @@ const components: Partial<Components> = {
|
||||
return <span className="mention">{children}</span>;
|
||||
}
|
||||
|
||||
// Regular links — open in new tab
|
||||
// Regular links — open directly on click
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) window.open(href, "_blank", "noopener,noreferrer");
|
||||
if (!href) return;
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -273,9 +281,11 @@ interface ReadonlyContentProps {
|
||||
|
||||
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
const processed = useMemo(() => preprocessMarkdown(content), [content]);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const hover = useLinkHover(wrapperRef);
|
||||
|
||||
return (
|
||||
<div className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
@@ -284,6 +294,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,9 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
content: `<p>${defaultValue}</p>`,
|
||||
content: defaultValue
|
||||
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
|
||||
: "",
|
||||
extensions: [
|
||||
SingleLineDocument,
|
||||
Paragraph,
|
||||
|
||||
@@ -32,6 +32,10 @@ export function preprocessMarkdown(markdown: string): string {
|
||||
*/
|
||||
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
function preprocessFileCards(markdown: string): string {
|
||||
return markdown
|
||||
.split("\n")
|
||||
@@ -42,7 +46,7 @@ function preprocessFileCards(markdown: string): string {
|
||||
const filename = match[1]!;
|
||||
const url = match[2]!;
|
||||
if (!isFileCardUrl(url)) return line;
|
||||
return `<div data-type="fileCard" data-href="${url}" data-filename="${filename}"></div>`;
|
||||
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ function CommentRow({
|
||||
className="relative mt-1.5 pl-8"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<div className="text-sm leading-relaxed">
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
@@ -484,7 +484,7 @@ function CommentCard({
|
||||
className="relative pl-10"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<div className="text-sm leading-relaxed">
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -52,10 +54,10 @@ import {
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
|
||||
import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry, Issue } from "@multica/core/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
|
||||
import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
@@ -174,6 +176,132 @@ function PropRow({
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue Picker Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IssuePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
excludeIds,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
excludeIds: string[];
|
||||
onSelect: (issue: Issue) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Issue[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const abortRef = useRef<AbortController>(undefined);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = useCallback(
|
||||
(q: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await api.searchIssues({
|
||||
q: q.trim(),
|
||||
limit: 20,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!controller.signal.aborted) {
|
||||
setResults(
|
||||
res.issues.filter((i) => !excludeIds.includes(i.id)),
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[excludeIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={description}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search issues..."
|
||||
value={query}
|
||||
onValueChange={(v) => {
|
||||
setQuery(v);
|
||||
search(v);
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{isLoading && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && query.trim() && results.length === 0 && (
|
||||
<CommandEmpty>No issues found.</CommandEmpty>
|
||||
)}
|
||||
{!isLoading && !query.trim() && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search issues
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<CommandGroup>
|
||||
{results.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
onSelect={() => {
|
||||
onSelect(issue);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0">{issue.identifier}</span>
|
||||
<span className="truncate">{issue.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,6 +349,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
const [parentPickerOpen, setParentPickerOpen] = useState(false);
|
||||
const [childPickerOpen, setChildPickerOpen] = useState(false);
|
||||
|
||||
// Issue data from TQ — uses detail query, seeded from list cache if available.
|
||||
// Only seed when description is present; list API omits it, and ContentEditor
|
||||
@@ -645,6 +775,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
Create sub-issue
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Add as sub-issue of another issue */}
|
||||
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
Set parent issue...
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Add another issue as sub-issue */}
|
||||
<DropdownMenuItem onClick={() => setChildPickerOpen(true)}>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
Add sub-issue...
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Pin / Unpin */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
if (isPinned) {
|
||||
@@ -724,6 +866,35 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Set parent issue picker */}
|
||||
<IssuePickerDialog
|
||||
open={parentPickerOpen}
|
||||
onOpenChange={setParentPickerOpen}
|
||||
title="Set parent issue"
|
||||
description="Search for an issue to set as the parent of this issue"
|
||||
excludeIds={[id, ...childIssues.map((c) => c.id)]}
|
||||
onSelect={(selected) => {
|
||||
handleUpdateField({ parent_issue_id: selected.id });
|
||||
toast.success(`Set ${selected.identifier} as parent issue`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add sub-issue picker */}
|
||||
<IssuePickerDialog
|
||||
open={childPickerOpen}
|
||||
onOpenChange={setChildPickerOpen}
|
||||
title="Add sub-issue"
|
||||
description="Search for an issue to add as a sub-issue"
|
||||
excludeIds={[id, ...(parentIssueId ? [parentIssueId] : []), ...childIssues.map((c) => c.id)]}
|
||||
onSelect={(selected) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: selected.id, parent_issue_id: id },
|
||||
{ onError: () => toast.error("Failed to add sub-issue") },
|
||||
);
|
||||
toast.success(`Added ${selected.identifier} as sub-issue`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content — scrollable */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { AppLink } from "../../navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
@@ -15,7 +15,16 @@ interface IssueMentionCardProps {
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
const listIssue = issues.find((i) => i.id === issueId);
|
||||
|
||||
// Fetch individual issue when not found in the list (e.g. done issues beyond
|
||||
// the first page). Only fires when listIssue is undefined.
|
||||
const { data: detailIssue } = useQuery({
|
||||
...issueDetailOptions(wsId, issueId),
|
||||
enabled: !listIssue,
|
||||
});
|
||||
|
||||
const issue = listIssue ?? detailIssue;
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,8 @@ interface DashboardLayoutProps {
|
||||
searchSlot?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Path to redirect when user is not authenticated */
|
||||
loginPath?: string;
|
||||
/** Path to redirect when user has no workspace */
|
||||
onboardingPath?: string;
|
||||
}
|
||||
@@ -23,11 +25,12 @@ export function DashboardLayout({
|
||||
extra,
|
||||
searchSlot,
|
||||
loadingIndicator,
|
||||
loginPath,
|
||||
onboardingPath,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<DashboardGuard
|
||||
loginPath="/"
|
||||
loginPath={loginPath}
|
||||
onboardingPath={onboardingPath}
|
||||
loadingFallback={
|
||||
<div className="flex h-svh items-center justify-center">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const { pathname, replace } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
@@ -15,13 +15,13 @@ export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!user) {
|
||||
push(loginPath);
|
||||
replace(loginPath);
|
||||
return;
|
||||
}
|
||||
if (!workspace && onboardingPath) {
|
||||
push(onboardingPath);
|
||||
replace(onboardingPath);
|
||||
}
|
||||
}, [user, isLoading, workspace, push, loginPath, onboardingPath]);
|
||||
}, [user, isLoading, workspace, replace, loginPath, onboardingPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@tiptap/core": "^3.22.1",
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -123,6 +123,9 @@ importers:
|
||||
'@multica/views':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/views
|
||||
electron-updater:
|
||||
specifier: ^6.8.3
|
||||
version: 6.8.3
|
||||
react-router-dom:
|
||||
specifier: ^7.6.0
|
||||
version: 7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -3642,6 +3645,9 @@ packages:
|
||||
electron-to-chromium@1.5.321:
|
||||
resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
|
||||
|
||||
electron-updater@6.8.3:
|
||||
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
|
||||
|
||||
electron-vite@5.0.0:
|
||||
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4839,6 +4845,13 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -6352,6 +6365,9 @@ packages:
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tiny-typed-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -9800,6 +9816,19 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.321: {}
|
||||
|
||||
electron-updater@6.8.3:
|
||||
dependencies:
|
||||
builder-util-runtime: 9.5.1
|
||||
fs-extra: 10.1.0
|
||||
js-yaml: 4.1.1
|
||||
lazy-val: 1.0.5
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.isequal: 4.5.0
|
||||
semver: 7.7.4
|
||||
tiny-typed-emitter: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron-vite@5.0.0(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -11262,6 +11291,10 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
@@ -13331,6 +13364,8 @@ snapshots:
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tiny-typed-emitter@2.1.0: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.4: {}
|
||||
|
||||
@@ -79,7 +79,7 @@ func openBrowser(url string) error {
|
||||
args = []string{url}
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", url}
|
||||
args = []string{"/c", "start", "", url}
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
@@ -98,15 +98,31 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
|
||||
serverURL := resolveServerURL(cmd)
|
||||
appURL := resolveAppURL(cmd)
|
||||
|
||||
// Determine the callback host from the configured app URL.
|
||||
// For self-hosted setups where the browser is on a different machine
|
||||
// (e.g. Multica running on a LAN server), use the server's private IP
|
||||
// so the browser can reach the CLI's local HTTP server.
|
||||
// For production (public hostnames like multica.ai), keep localhost —
|
||||
// the browser and CLI are on the same machine.
|
||||
callbackHost := "localhost"
|
||||
bindAddr := "127.0.0.1"
|
||||
if parsed, err := url.Parse(appURL); err == nil {
|
||||
h := parsed.Hostname()
|
||||
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
|
||||
callbackHost = h
|
||||
bindAddr = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// Start a local HTTP server on a random port to receive the callback.
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
listener, err := net.Listen("tcp", bindAddr+":0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start local server: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
callbackURL := fmt.Sprintf("http://localhost:%d/callback", port)
|
||||
callbackURL := fmt.Sprintf("http://%s:%d/callback", callbackHost, port)
|
||||
|
||||
// Generate a random state parameter for CSRF protection.
|
||||
stateBytes := make([]byte, 16)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -161,6 +162,7 @@ func init() {
|
||||
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)")
|
||||
issueUpdateCmd.Flags().String("project", "", "Project ID")
|
||||
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
|
||||
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
|
||||
issueUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// issue status
|
||||
@@ -185,7 +187,8 @@ func init() {
|
||||
issueRunMessagesCmd.Flags().Int("since", 0, "Only return messages after this sequence number")
|
||||
|
||||
// issue comment add
|
||||
issueCommentAddCmd.Flags().String("content", "", "Comment content (required)")
|
||||
issueCommentAddCmd.Flags().String("content", "", "Comment content (required unless --content-stdin)")
|
||||
issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (avoids shell escaping issues)")
|
||||
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
|
||||
issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
|
||||
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
@@ -442,6 +445,14 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
|
||||
body["assignee_type"] = aType
|
||||
body["assignee_id"] = aID
|
||||
}
|
||||
if cmd.Flags().Changed("parent") {
|
||||
v, _ := cmd.Flags().GetString("parent")
|
||||
if v == "" {
|
||||
body["parent_issue_id"] = nil
|
||||
} else {
|
||||
body["parent_issue_id"] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use flags like --title, --status, --priority, --assignee, etc.")
|
||||
@@ -637,8 +648,25 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
|
||||
|
||||
func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
|
||||
content, _ := cmd.Flags().GetString("content")
|
||||
useStdin, _ := cmd.Flags().GetBool("content-stdin")
|
||||
|
||||
if content != "" && useStdin {
|
||||
return fmt.Errorf("--content and --content-stdin are mutually exclusive")
|
||||
}
|
||||
|
||||
if useStdin {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
content = strings.TrimSuffix(string(data), "\n")
|
||||
if content == "" {
|
||||
return fmt.Errorf("stdin content is empty")
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return fmt.Errorf("--content is required")
|
||||
return fmt.Errorf("--content or --content-stdin is required")
|
||||
}
|
||||
|
||||
client, err := newAPIClient(cmd)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -11,6 +12,22 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
// tryResolveAppURL returns the app URL if configured, or "" if not available.
|
||||
// Unlike resolveAppURL, it never calls os.Exit.
|
||||
func tryResolveAppURL(cmd *cobra.Command) string {
|
||||
for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} {
|
||||
if val := strings.TrimSpace(os.Getenv(key)); val != "" {
|
||||
return strings.TrimRight(val, "/")
|
||||
}
|
||||
}
|
||||
profile := resolveProfile(cmd)
|
||||
cfg, err := cli.LoadCLIConfigForProfile(profile)
|
||||
if err == nil && cfg.AppURL != "" {
|
||||
return strings.TrimRight(cfg.AppURL, "/")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate and set up workspaces",
|
||||
@@ -59,8 +76,15 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
|
||||
return nil
|
||||
var err error
|
||||
workspaces, err = waitForOnboarding(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
profile := resolveProfile(cmd)
|
||||
@@ -96,3 +120,54 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForOnboarding opens the web onboarding page and polls until the user
|
||||
// creates a workspace, returning the new workspace list.
|
||||
func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}, error) {
|
||||
appURL := tryResolveAppURL(cmd)
|
||||
if appURL == "" {
|
||||
// No app URL available (e.g. token login without prior setup).
|
||||
// Can't open the browser — tell the user to create a workspace manually.
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found.")
|
||||
fmt.Fprintln(os.Stderr, "Create a workspace in the web dashboard, then run 'multica login' again.")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
onboardingURL := appURL + "/onboarding"
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening onboarding in your browser...")
|
||||
if err := openBrowser(onboardingURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", onboardingURL)
|
||||
fmt.Fprintln(os.Stderr, "\nWaiting for workspace creation...")
|
||||
|
||||
// Poll until a workspace appears or timeout (5 minutes).
|
||||
const pollInterval = 2 * time.Second
|
||||
const pollTimeout = 5 * time.Minute
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
var workspaces []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := client.GetJSON(ctx, "/api/workspaces", &workspaces)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue // transient error, keep polling
|
||||
}
|
||||
if len(workspaces) > 0 {
|
||||
return workspaces, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("timed out waiting for workspace creation")
|
||||
}
|
||||
|
||||
@@ -135,13 +135,18 @@ func runSetupCloud(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start daemon in background.
|
||||
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
||||
if err := runDaemonBackground(cmd); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
// Start daemon only if we have workspaces to watch.
|
||||
if hasWatchedWorkspaces(resolveProfile(cmd)) {
|
||||
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
||||
if err := runDaemonBackground(cmd); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "\n⚠ Setup incomplete: no workspaces configured.")
|
||||
fmt.Fprintln(os.Stderr, "Create a workspace at the web dashboard, then run 'multica login' and 'multica daemon start'.")
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -195,16 +200,30 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start daemon in background.
|
||||
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
||||
if err := runDaemonBackground(cmd); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
// Start daemon only if we have workspaces to watch.
|
||||
if hasWatchedWorkspaces(resolveProfile(cmd)) {
|
||||
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
||||
if err := runDaemonBackground(cmd); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "\n⚠ Setup incomplete: no workspaces configured.")
|
||||
fmt.Fprintln(os.Stderr, "Create a workspace at the web dashboard, then run 'multica login' and 'multica daemon start'.")
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasWatchedWorkspaces returns true if the CLI config has at least one watched workspace.
|
||||
func hasWatchedWorkspaces(profile string) bool {
|
||||
cfg, err := cli.LoadCLIConfigForProfile(profile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(cfg.WatchedWorkspaces) > 0
|
||||
}
|
||||
|
||||
// probeServer checks whether a Multica backend is reachable at the given URL.
|
||||
func probeServer(baseURL string) bool {
|
||||
url := strings.TrimRight(baseURL, "/") + "/health"
|
||||
|
||||
@@ -153,6 +153,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
// --- User-scoped routes (no workspace context required) ---
|
||||
r.Get("/api/me", h.GetMe)
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Post("/api/cli-token", h.IssueCliToken)
|
||||
r.Post("/api/upload-file", h.UploadFile)
|
||||
|
||||
r.Route("/api/workspaces", func(r chi.Router) {
|
||||
|
||||
@@ -18,6 +18,9 @@ const (
|
||||
// staleThresholdSeconds marks runtimes offline if no heartbeat for this long.
|
||||
// The daemon heartbeat interval is 15s, so 45s = 3 missed heartbeats.
|
||||
staleThresholdSeconds = 45.0
|
||||
// offlineRuntimeTTLSeconds deletes offline runtimes with no active agents
|
||||
// after this duration. 7 days gives users plenty of time to restart daemons.
|
||||
offlineRuntimeTTLSeconds = 7 * 24 * 3600.0
|
||||
// dispatchTimeoutSeconds fails tasks stuck in 'dispatched' beyond this.
|
||||
// The dispatched→running transition should be near-instant, so 5 minutes
|
||||
// means something went wrong (e.g. StartTask API call failed silently).
|
||||
@@ -42,6 +45,7 @@ func runRuntimeSweeper(ctx context.Context, queries *db.Queries, bus *events.Bus
|
||||
case <-ticker.C:
|
||||
sweepStaleRuntimes(ctx, queries, bus)
|
||||
sweepStaleTasks(ctx, queries, bus)
|
||||
gcRuntimes(ctx, queries, bus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +93,38 @@ func sweepStaleRuntimes(ctx context.Context, queries *db.Queries, bus *events.Bu
|
||||
}
|
||||
}
|
||||
|
||||
// gcRuntimes deletes offline runtimes that have exceeded the TTL and have
|
||||
// no active (non-archived) agents. Before deleting, it cleans up any
|
||||
// archived agents so the FK constraint (ON DELETE RESTRICT) doesn't block.
|
||||
func gcRuntimes(ctx context.Context, queries *db.Queries, bus *events.Bus) {
|
||||
deleted, err := queries.DeleteStaleOfflineRuntimes(ctx, offlineRuntimeTTLSeconds)
|
||||
if err != nil {
|
||||
slog.Warn("runtime GC: failed to delete stale offline runtimes", "error", err)
|
||||
return
|
||||
}
|
||||
if len(deleted) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
gcWorkspaces := make(map[string]bool)
|
||||
for _, row := range deleted {
|
||||
gcWorkspaces[util.UUIDToString(row.WorkspaceID)] = true
|
||||
}
|
||||
|
||||
slog.Info("runtime GC: deleted stale offline runtimes", "count", len(deleted), "workspaces", len(gcWorkspaces))
|
||||
|
||||
for wsID := range gcWorkspaces {
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventDaemonRegister,
|
||||
WorkspaceID: wsID,
|
||||
ActorType: "system",
|
||||
Payload: map[string]any{
|
||||
"action": "runtime_gc",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sweepStaleTasks fails tasks stuck in dispatched/running for too long,
|
||||
// even when the runtime is still online. This handles cases where:
|
||||
// - The agent process hangs and the daemon is still heartbeating
|
||||
|
||||
@@ -14,11 +14,8 @@ import (
|
||||
)
|
||||
|
||||
// APIClient is a REST client for the Multica server API.
|
||||
// Used by ctrl subcommands (agent, runtime, status, etc.).
|
||||
//
|
||||
// TODO: Add Authorization header support. Agent routes (/api/agents/...)
|
||||
// require JWT auth via middleware.Auth, but this client currently sends
|
||||
// no auth token. CLI agent commands will fail with 401 until this is added.
|
||||
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
|
||||
// automatically include auth and execution context headers when configured.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
WorkspaceID string
|
||||
|
||||
@@ -84,17 +84,25 @@ func TestPostJSON(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workspace header", func(t *testing.T) {
|
||||
t.Run("workspace and agent context headers", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ws := r.Header.Get("X-Workspace-ID"); ws != "ws-abc" {
|
||||
t.Errorf("expected X-Workspace-ID ws-abc, got %s", ws)
|
||||
}
|
||||
if agent := r.Header.Get("X-Agent-ID"); agent != "agent-123" {
|
||||
t.Errorf("expected X-Agent-ID agent-123, got %s", agent)
|
||||
}
|
||||
if task := r.Header.Get("X-Task-ID"); task != "task-456" {
|
||||
t.Errorf("expected X-Task-ID task-456, got %s", task)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody{ID: "456"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewAPIClient(srv.URL, "ws-abc", "test-token")
|
||||
client.AgentID = "agent-123"
|
||||
client.TaskID = "task-456"
|
||||
var out respBody
|
||||
err := client.PostJSON(context.Background(), "/test", reqBody{}, &out)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,7 @@ type Config struct {
|
||||
RuntimeName string
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry, "openclaw" -> entry, "hermes" -> entry
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
@@ -113,8 +113,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_HERMES_MODEL")),
|
||||
}
|
||||
}
|
||||
geminiPath := envOrDefault("MULTICA_GEMINI_PATH", "gemini")
|
||||
if _, err := exec.LookPath(geminiPath); err == nil {
|
||||
agents["gemini"] = AgentEntry{
|
||||
Path: geminiPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, or hermes and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, or gemini and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
@@ -164,11 +171,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
if overrides.DaemonID != "" {
|
||||
daemonID = overrides.DaemonID
|
||||
}
|
||||
// Suffix daemon ID with profile name to avoid collisions when multiple
|
||||
// daemons register against the same server.
|
||||
if profile != "" && !strings.HasSuffix(daemonID, "-"+profile) {
|
||||
daemonID = daemonID + "-" + profile
|
||||
}
|
||||
// NOTE: daemon_id is intentionally stable (hostname or explicit override).
|
||||
// The unique constraint (workspace_id, daemon_id, provider) already prevents
|
||||
// collisions within the same workspace. Appending the profile name caused
|
||||
// duplicate runtimes when users switched profiles.
|
||||
|
||||
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
|
||||
if overrides.DeviceName != "" {
|
||||
|
||||
@@ -525,7 +525,18 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
|
||||
}
|
||||
}()
|
||||
|
||||
result := <-session.Result
|
||||
var result agent.Result
|
||||
select {
|
||||
case result = <-session.Result:
|
||||
case <-pingCtx.Done():
|
||||
d.logger.Warn("ping timed out waiting for result", "runtime_id", rt.ID, "ping_id", pingID)
|
||||
d.client.ReportPingResult(ctx, rt.ID, pingID, map[string]any{
|
||||
"status": "failed",
|
||||
"error": "ping context cancelled while waiting for result",
|
||||
"duration_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
return
|
||||
}
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
if result.Status == "completed" {
|
||||
@@ -972,6 +983,20 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
if env.CodexHome != "" {
|
||||
agentEnv["CODEX_HOME"] = env.CodexHome
|
||||
}
|
||||
// Inject user-configured custom environment variables (e.g. ANTHROPIC_API_KEY,
|
||||
// ANTHROPIC_BASE_URL for router/proxy mode, or CLAUDE_CODE_USE_BEDROCK for
|
||||
// Bedrock). These are set per-agent via the agent settings UI.
|
||||
// Critical internal variables are blocklisted to prevent accidental or
|
||||
// malicious override of daemon-set values.
|
||||
if task.Agent != nil {
|
||||
for k, v := range task.Agent.CustomEnv {
|
||||
if isBlockedEnvKey(k) {
|
||||
d.logger.Warn("custom_env: blocked key skipped", "key", k)
|
||||
continue
|
||||
}
|
||||
agentEnv[k] = v
|
||||
}
|
||||
}
|
||||
backend, err := agent.New(provider, agent.Config{
|
||||
ExecutablePath: entry.Path,
|
||||
Env: agentEnv,
|
||||
@@ -1078,6 +1103,17 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
|
||||
return agent.Result{}, 0, err
|
||||
}
|
||||
|
||||
// Create an independent drain deadline so we don't block forever if the
|
||||
// backend's internal timeout fails to produce a Result (e.g. scanner
|
||||
// stuck on a hung stdout pipe). The extra 30 s gives the backend time
|
||||
// to clean up after its own timeout fires.
|
||||
drainTimeout := opts.Timeout + 30*time.Second
|
||||
if opts.Timeout == 0 {
|
||||
drainTimeout = 21 * time.Minute
|
||||
}
|
||||
drainCtx, drainCancel := context.WithTimeout(ctx, drainTimeout)
|
||||
defer drainCancel()
|
||||
|
||||
var toolCount atomic.Int32
|
||||
go func() {
|
||||
var seq atomic.Int32
|
||||
@@ -1135,77 +1171,92 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
|
||||
}
|
||||
}()
|
||||
|
||||
for msg := range session.Messages {
|
||||
switch msg.Type {
|
||||
case agent.MessageToolUse:
|
||||
n := toolCount.Add(1)
|
||||
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
|
||||
if msg.CallID != "" {
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-session.Messages:
|
||||
if !ok {
|
||||
goto drainDone
|
||||
}
|
||||
switch msg.Type {
|
||||
case agent.MessageToolUse:
|
||||
n := toolCount.Add(1)
|
||||
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
|
||||
if msg.CallID != "" {
|
||||
mu.Lock()
|
||||
callIDToTool[msg.CallID] = msg.Tool
|
||||
mu.Unlock()
|
||||
}
|
||||
s := seq.Add(1)
|
||||
mu.Lock()
|
||||
callIDToTool[msg.CallID] = msg.Tool
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "tool_use",
|
||||
Tool: msg.Tool,
|
||||
Input: msg.Input,
|
||||
})
|
||||
mu.Unlock()
|
||||
case agent.MessageToolResult:
|
||||
s := seq.Add(1)
|
||||
output := msg.Output
|
||||
if len(output) > 8192 {
|
||||
output = output[:8192]
|
||||
}
|
||||
toolName := msg.Tool
|
||||
if toolName == "" && msg.CallID != "" {
|
||||
mu.Lock()
|
||||
toolName = callIDToTool[msg.CallID]
|
||||
mu.Unlock()
|
||||
}
|
||||
mu.Lock()
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "tool_result",
|
||||
Tool: toolName,
|
||||
Output: output,
|
||||
})
|
||||
mu.Unlock()
|
||||
case agent.MessageThinking:
|
||||
if msg.Content != "" {
|
||||
mu.Lock()
|
||||
pendingThinking.WriteString(msg.Content)
|
||||
mu.Unlock()
|
||||
}
|
||||
case agent.MessageText:
|
||||
if msg.Content != "" {
|
||||
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
|
||||
mu.Lock()
|
||||
pendingText.WriteString(msg.Content)
|
||||
mu.Unlock()
|
||||
}
|
||||
case agent.MessageError:
|
||||
taskLog.Error("agent error", "content", msg.Content)
|
||||
s := seq.Add(1)
|
||||
mu.Lock()
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "error",
|
||||
Content: msg.Content,
|
||||
})
|
||||
mu.Unlock()
|
||||
}
|
||||
s := seq.Add(1)
|
||||
mu.Lock()
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "tool_use",
|
||||
Tool: msg.Tool,
|
||||
Input: msg.Input,
|
||||
})
|
||||
mu.Unlock()
|
||||
case agent.MessageToolResult:
|
||||
s := seq.Add(1)
|
||||
output := msg.Output
|
||||
if len(output) > 8192 {
|
||||
output = output[:8192]
|
||||
}
|
||||
toolName := msg.Tool
|
||||
if toolName == "" && msg.CallID != "" {
|
||||
mu.Lock()
|
||||
toolName = callIDToTool[msg.CallID]
|
||||
mu.Unlock()
|
||||
}
|
||||
mu.Lock()
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "tool_result",
|
||||
Tool: toolName,
|
||||
Output: output,
|
||||
})
|
||||
mu.Unlock()
|
||||
case agent.MessageThinking:
|
||||
if msg.Content != "" {
|
||||
mu.Lock()
|
||||
pendingThinking.WriteString(msg.Content)
|
||||
mu.Unlock()
|
||||
}
|
||||
case agent.MessageText:
|
||||
if msg.Content != "" {
|
||||
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
|
||||
mu.Lock()
|
||||
pendingText.WriteString(msg.Content)
|
||||
mu.Unlock()
|
||||
}
|
||||
case agent.MessageError:
|
||||
taskLog.Error("agent error", "content", msg.Content)
|
||||
s := seq.Add(1)
|
||||
mu.Lock()
|
||||
batch = append(batch, TaskMessageData{
|
||||
Seq: int(s),
|
||||
Type: "error",
|
||||
Content: msg.Content,
|
||||
})
|
||||
mu.Unlock()
|
||||
case <-drainCtx.Done():
|
||||
goto drainDone
|
||||
}
|
||||
}
|
||||
|
||||
drainDone:
|
||||
close(done)
|
||||
flush()
|
||||
}()
|
||||
|
||||
result := <-session.Result
|
||||
return result, toolCount.Load(), nil
|
||||
select {
|
||||
case result := <-session.Result:
|
||||
return result, toolCount.Load(), nil
|
||||
case <-drainCtx.Done():
|
||||
return agent.Result{
|
||||
Status: "timeout",
|
||||
Error: "agent did not produce result within drain timeout",
|
||||
}, toolCount.Load(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func mergeUsage(a, b map[string]agent.TokenUsage) map[string]agent.TokenUsage {
|
||||
@@ -1288,3 +1339,18 @@ func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// isBlockedEnvKey returns true if the key must not be overridden by user-
|
||||
// configured custom_env. This prevents accidental or malicious override of
|
||||
// daemon-internal variables and critical system paths.
|
||||
func isBlockedEnvKey(key string) bool {
|
||||
upper := strings.ToUpper(key)
|
||||
if strings.HasPrefix(upper, "MULTICA_") {
|
||||
return true
|
||||
}
|
||||
switch upper {
|
||||
case "HOME", "PATH", "USER", "SHELL", "TERM", "CODEX_HOME":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -390,6 +390,44 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigGemini(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "test-issue-id",
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "GEMINI.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read GEMINI.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"Multica Agent Runtime",
|
||||
"multica issue get",
|
||||
"Writing",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("GEMINI.md missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Should not write CLAUDE.md or AGENTS.md for gemini provider.
|
||||
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
|
||||
t.Error("gemini provider should not create CLAUDE.md")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) {
|
||||
t.Error("gemini provider should not create AGENTS.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigCodex(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// 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)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
@@ -22,6 +23,8 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "opencode", "openclaw":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
return nil
|
||||
@@ -75,6 +78,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent <issue-id>] [--status X]` — Create a new issue\n")
|
||||
b.WriteString("- `multica issue assign <id> --to <name>` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n")
|
||||
b.WriteString("- `multica issue comment add <issue-id> --content \"...\" [--parent <comment-id>]` — Post a comment (use --parent to reply to a specific comment)\n")
|
||||
b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add <issue-id> --content-stdin`\n")
|
||||
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
|
||||
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
|
||||
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
|
||||
@@ -110,11 +114,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- Keep responses concise and direct\n\n")
|
||||
} else if ctx.TriggerCommentID != "" {
|
||||
// Comment-triggered: focus on reading and replying
|
||||
b.WriteString("**This task was triggered by a comment.** Your primary job is to respond.\n\n")
|
||||
b.WriteString("**This task was triggered by a NEW comment.** Your primary job is to respond to THIS specific comment, even if you have handled similar requests before in this session.\n\n")
|
||||
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
|
||||
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
|
||||
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked\n", ctx.TriggerCommentID)
|
||||
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
|
||||
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
|
||||
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")
|
||||
b.WriteString("6. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
|
||||
@@ -139,6 +143,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
case "codex", "opencode", "openclaw":
|
||||
// Codex, OpenCode, and OpenClaw discover skills natively from their respective paths — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "gemini":
|
||||
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
default:
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func buildCommentPrompt(task Task) string {
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
||||
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
||||
if task.TriggerCommentContent != "" {
|
||||
b.WriteString("A user left a comment that triggered this task. Here is their message:\n\n")
|
||||
b.WriteString("[NEW COMMENT] A user just left a new comment that triggered this task. You MUST respond to THIS comment, not any previous ones:\n\n")
|
||||
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
|
||||
}
|
||||
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
|
||||
|
||||
@@ -40,10 +40,11 @@ type Task struct {
|
||||
|
||||
// AgentData holds agent details returned by the claim endpoint.
|
||||
type AgentData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Instructions string `json:"instructions"`
|
||||
Skills []SkillData `json:"skills"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Instructions string `json:"instructions"`
|
||||
Skills []SkillData `json:"skills"`
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
}
|
||||
|
||||
// SkillData represents a structured skill for task execution.
|
||||
|
||||
@@ -14,24 +14,25 @@ import (
|
||||
)
|
||||
|
||||
type AgentResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instructions string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []SkillResponse `json:"skills"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instructions string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv map[string]string `json:"custom_env"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []SkillResponse `json:"skills"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
@@ -43,6 +44,16 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
rc = map[string]any{}
|
||||
}
|
||||
|
||||
var customEnv map[string]string
|
||||
if a.CustomEnv != nil {
|
||||
if err := json.Unmarshal(a.CustomEnv, &customEnv); err != nil {
|
||||
slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(a.ID), "error", err)
|
||||
}
|
||||
}
|
||||
if customEnv == nil {
|
||||
customEnv = map[string]string{}
|
||||
}
|
||||
|
||||
return AgentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
@@ -53,6 +64,7 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
AvatarURL: textToPtr(a.AvatarUrl),
|
||||
RuntimeMode: a.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
CustomEnv: customEnv,
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
@@ -103,6 +115,7 @@ type TaskAgentData struct {
|
||||
Name string `json:"name"`
|
||||
Instructions string `json:"instructions"`
|
||||
Skills []service.AgentSkillData `json:"skills,omitempty"`
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
@@ -196,14 +209,15 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type CreateAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instructions string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Instructions string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv map[string]string `json:"custom_env"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -249,6 +263,11 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
rc = []byte("{}")
|
||||
}
|
||||
|
||||
ce, _ := json.Marshal(req.CustomEnv)
|
||||
if req.CustomEnv == nil {
|
||||
ce = []byte("{}")
|
||||
}
|
||||
|
||||
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: req.Name,
|
||||
@@ -261,6 +280,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
CustomEnv: ce,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create agent failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
@@ -283,15 +303,16 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Instructions *string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeID *string `json:"runtime_id"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Instructions *string `json:"instructions"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeID *string `json:"runtime_id"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
CustomEnv *map[string]string `json:"custom_env"`
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
// canManageAgent checks whether the current user can update or archive an agent.
|
||||
@@ -347,6 +368,10 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
rc, _ := json.Marshal(req.RuntimeConfig)
|
||||
params.RuntimeConfig = rc
|
||||
}
|
||||
if req.CustomEnv != nil {
|
||||
ce, _ := json.Marshal(*req.CustomEnv)
|
||||
params.CustomEnv = ce
|
||||
}
|
||||
if req.RuntimeID != nil {
|
||||
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: parseUUID(*req.RuntimeID),
|
||||
|
||||
@@ -110,9 +110,9 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: max 1 code per 10 seconds per email
|
||||
// Rate limit: max 1 code per 60 seconds per email
|
||||
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
||||
if err == nil && time.Since(latest.CreatedAt.Time) < 10*time.Second {
|
||||
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
|
||||
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
|
||||
return
|
||||
}
|
||||
@@ -390,6 +390,31 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// IssueCliToken returns a fresh JWT for the authenticated user.
|
||||
// This allows cookie-authenticated browser sessions to obtain a bearer token
|
||||
// that can be handed off to the CLI via the cli_callback redirect.
|
||||
func (h *Handler) IssueCliToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := h.issueJWT(user)
|
||||
if err != nil {
|
||||
slog.Warn("cli-token: failed to issue JWT", append(logger.RequestAttrs(r), "error", err, "user_id", userID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"token": tokenString})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
auth.ClearAuthCookies(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
||||
|
||||
@@ -217,6 +217,28 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Migrate agents from old offline runtimes on the same machine to the
|
||||
// newly registered runtime. Uses the runtime's owner_id (preserved via
|
||||
// COALESCE on upsert) so migration works with both PAT and daemon tokens.
|
||||
// Scoped by daemon_id prefix so that only old profile-suffixed runtimes
|
||||
// (e.g. "hostname-staging") from this machine are affected.
|
||||
effectiveOwnerID := registered.OwnerID
|
||||
if effectiveOwnerID.Valid {
|
||||
migrated, err := h.Queries.MigrateAgentsToRuntime(r.Context(), db.MigrateAgentsToRuntimeParams{
|
||||
NewRuntimeID: registered.ID,
|
||||
WorkspaceID: parseUUID(req.WorkspaceID),
|
||||
Provider: provider,
|
||||
OwnerID: effectiveOwnerID,
|
||||
DaemonIDPrefix: strToText(req.DaemonID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to migrate agents to new runtime", "runtime_id", uuidToString(registered.ID), "error", err)
|
||||
} else if migrated > 0 {
|
||||
slog.Info("migrated agents to new runtime", "runtime_id", uuidToString(registered.ID), "provider", provider, "migrated_count", migrated)
|
||||
}
|
||||
}
|
||||
|
||||
resp = append(resp, runtimeToResponse(registered))
|
||||
}
|
||||
|
||||
@@ -358,15 +380,22 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build response with fresh agent data (name + skills).
|
||||
// Build response with fresh agent data (name + skills + custom_env).
|
||||
resp := taskToResponse(*task)
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
|
||||
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
|
||||
var customEnv map[string]string
|
||||
if agent.CustomEnv != nil {
|
||||
if err := json.Unmarshal(agent.CustomEnv, &customEnv); err != nil {
|
||||
slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(agent.ID), "error", err)
|
||||
}
|
||||
}
|
||||
resp.Agent = &TaskAgentData{
|
||||
ID: uuidToString(agent.ID),
|
||||
Name: agent.Name,
|
||||
Instructions: agent.Instructions,
|
||||
Skills: skills,
|
||||
CustomEnv: customEnv,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,17 +160,21 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
key := id.String() + path.Ext(header.Filename)
|
||||
|
||||
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
||||
if err != nil {
|
||||
slog.Error("file upload failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
filename := id.String() + path.Ext(header.Filename)
|
||||
var key string
|
||||
if workspaceID != "" {
|
||||
key = "workspaces/" + workspaceID + "/" + filename
|
||||
} else {
|
||||
key = "users/" + userID + "/" + filename
|
||||
}
|
||||
|
||||
// If workspace context is available, create an attachment record.
|
||||
// If workspace context is available, validate membership before uploading.
|
||||
if workspaceID != "" {
|
||||
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
|
||||
|
||||
params := db.CreateAttachmentParams{
|
||||
@@ -179,12 +183,10 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
UploaderType: uploaderType,
|
||||
UploaderID: parseUUID(uploaderID),
|
||||
Filename: header.Filename,
|
||||
Url: link,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(data)),
|
||||
}
|
||||
|
||||
// Optional issue_id / comment_id from form fields — validate ownership.
|
||||
if issueID := r.FormValue("issue_id"); issueID != "" {
|
||||
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: parseUUID(issueID),
|
||||
@@ -205,6 +207,14 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
params.CommentID = comment.ID
|
||||
}
|
||||
|
||||
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
||||
if err != nil {
|
||||
slog.Error("file upload failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
}
|
||||
params.Url = link
|
||||
|
||||
att, err := h.Queries.CreateAttachment(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Error("failed to create attachment record", "error", err)
|
||||
@@ -214,9 +224,21 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"filename": header.Filename,
|
||||
"link": link,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback response (no workspace context, e.g. avatar upload)
|
||||
// No workspace context (e.g. avatar upload) — upload directly.
|
||||
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
||||
if err != nil {
|
||||
slog.Error("file upload failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"filename": header.Filename,
|
||||
"link": link,
|
||||
|
||||
48
server/internal/handler/file_test.go
Normal file
48
server/internal/handler/file_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockStorage struct{}
|
||||
|
||||
func (m *mockStorage) Upload(_ context.Context, key string, _ []byte, _ string, _ string) (string, error) {
|
||||
return fmt.Sprintf("https://cdn.example.com/%s", key), nil
|
||||
}
|
||||
|
||||
func (m *mockStorage) Delete(_ context.Context, _ string) {}
|
||||
func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {}
|
||||
func (m *mockStorage) KeyFromURL(rawURL string) string { return rawURL }
|
||||
|
||||
func TestUploadFileForeignWorkspace(t *testing.T) {
|
||||
origStorage := testHandler.Storage
|
||||
testHandler.Storage = &mockStorage{}
|
||||
defer func() { testHandler.Storage = origStorage }()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "test.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write([]byte("hello world"))
|
||||
writer.Close()
|
||||
|
||||
foreignWorkspaceID := "00000000-0000-0000-0000-000000000099"
|
||||
req := httptest.NewRequest("POST", "/api/upload-file", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", testUserID)
|
||||
req.Header.Set("X-Workspace-ID", foreignWorkspaceID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.UploadFile(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("UploadFile with foreign workspace: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,24 @@ func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, w
|
||||
return member, true
|
||||
}
|
||||
|
||||
// isWorkspaceEntity checks whether a user_id belongs to the given workspace,
|
||||
// as either a member or an agent depending on userType.
|
||||
func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, workspaceID string) bool {
|
||||
switch userType {
|
||||
case "member":
|
||||
_, err := h.getWorkspaceMember(ctx, userID, workspaceID)
|
||||
return err == nil
|
||||
case "agent":
|
||||
_, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
||||
ID: parseUUID(userID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
|
||||
if _, ok := requireUserID(w, r); !ok {
|
||||
return db.Issue{}, false
|
||||
|
||||
@@ -302,6 +302,156 @@ func TestCreateIssueExplicitBacklogPreserved(t *testing.T) {
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
||||
}
|
||||
|
||||
func TestCreateSubIssueInheritsParentProject(t *testing.T) {
|
||||
var projectID, parentID, childID string
|
||||
defer func() {
|
||||
for _, issueID := range []string{childID, parentID} {
|
||||
if issueID == "" {
|
||||
continue
|
||||
}
|
||||
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), req)
|
||||
}
|
||||
if projectID != "" {
|
||||
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
|
||||
req = withURLParam(req, "id", projectID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), req)
|
||||
}
|
||||
}()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Sub-issue inheritance project",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
json.NewDecoder(w.Body).Decode(&project)
|
||||
projectID = project.ID
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Parent with project",
|
||||
"project_id": projectID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&parent)
|
||||
parentID = parent.ID
|
||||
if parent.ProjectID == nil || *parent.ProjectID != projectID {
|
||||
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", projectID, parent.ProjectID)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Child without explicit project",
|
||||
"parent_issue_id": parentID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&child)
|
||||
childID = child.ID
|
||||
|
||||
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
|
||||
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
|
||||
}
|
||||
if child.ProjectID == nil || *child.ProjectID != projectID {
|
||||
t.Fatalf("CreateIssue child: expected inherited project_id %q, got %v", projectID, child.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSubIssueUsesExplicitProjectOverParentProject(t *testing.T) {
|
||||
var parentProjectID, childProjectID, parentID, childID string
|
||||
defer func() {
|
||||
for _, issueID := range []string{childID, parentID} {
|
||||
if issueID == "" {
|
||||
continue
|
||||
}
|
||||
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), req)
|
||||
}
|
||||
for _, projectID := range []string{childProjectID, parentProjectID} {
|
||||
if projectID == "" {
|
||||
continue
|
||||
}
|
||||
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
|
||||
req = withURLParam(req, "id", projectID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), req)
|
||||
}
|
||||
}()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Parent project",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parentProject ProjectResponse
|
||||
json.NewDecoder(w.Body).Decode(&parentProject)
|
||||
parentProjectID = parentProject.ID
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Child explicit project",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var childProject ProjectResponse
|
||||
json.NewDecoder(w.Body).Decode(&childProject)
|
||||
childProjectID = childProject.ID
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Parent with project",
|
||||
"project_id": parentProjectID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&parent)
|
||||
parentID = parent.ID
|
||||
if parent.ProjectID == nil || *parent.ProjectID != parentProjectID {
|
||||
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", parentProjectID, parent.ProjectID)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Child with explicit project",
|
||||
"parent_issue_id": parentID,
|
||||
"project_id": childProjectID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&child)
|
||||
childID = child.ID
|
||||
|
||||
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
|
||||
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
|
||||
}
|
||||
if child.ProjectID == nil || *child.ProjectID != childProjectID {
|
||||
t.Fatalf("CreateIssue child: expected explicit project_id %q, got %v", childProjectID, child.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCRUD(t *testing.T) {
|
||||
// Create an issue first
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -815,6 +815,10 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var parentIssueID pgtype.UUID
|
||||
var projectID pgtype.UUID
|
||||
if req.ProjectID != nil {
|
||||
projectID = parseUUID(*req.ProjectID)
|
||||
}
|
||||
if req.ParentIssueID != nil {
|
||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
// Validate parent exists in the same workspace.
|
||||
@@ -826,6 +830,9 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
||||
return
|
||||
}
|
||||
if req.ProjectID == nil {
|
||||
projectID = parent.ProjectID
|
||||
}
|
||||
}
|
||||
|
||||
var dueDate pgtype.Timestamptz
|
||||
@@ -872,7 +879,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
Number: issueNumber,
|
||||
ProjectID: func() pgtype.UUID { if req.ProjectID != nil { return parseUUID(*req.ProjectID) }; return pgtype.UUID{} }(),
|
||||
ProjectID: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
@@ -1115,6 +1122,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel active tasks when the issue is cancelled by a user.
|
||||
// This is distinct from agent-managed status transitions — cancellation
|
||||
// is a user-initiated terminal action that should stop execution.
|
||||
if statusChanged && issue.Status == "cancelled" {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -1404,6 +1418,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel active tasks when the issue is cancelled by a user.
|
||||
if statusChanged && issue.Status == "cancelled" {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
}
|
||||
|
||||
updated++
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
|
||||
RuntimeID: parseUUID(runtimeID),
|
||||
Since: since,
|
||||
Date: since,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
||||
|
||||
@@ -76,6 +76,12 @@ 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
|
||||
}
|
||||
|
||||
err := h.Queries.AddIssueSubscriber(r.Context(), db.AddIssueSubscriberParams{
|
||||
IssueID: issue.ID,
|
||||
UserType: targetUserType,
|
||||
@@ -87,7 +93,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
callerID := requestUserID(r)
|
||||
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
|
||||
@@ -125,6 +130,12 @@ 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
|
||||
}
|
||||
|
||||
err := h.Queries.RemoveIssueSubscriber(r.Context(), db.RemoveIssueSubscriberParams{
|
||||
IssueID: issue.ID,
|
||||
UserType: targetUserType,
|
||||
@@ -135,7 +146,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
callerID := requestUserID(r)
|
||||
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
|
||||
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{
|
||||
|
||||
@@ -174,6 +174,52 @@ func TestSubscriberAPI(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
|
||||
"user_id": foreignUserID,
|
||||
"user_type": "member",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.SubscribeToIssue(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
UserType: "member",
|
||||
UserID: parseUUID(foreignUserID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IsIssueSubscriber: %v", err)
|
||||
}
|
||||
if subscribed {
|
||||
t.Fatal("cross-workspace user should NOT be subscribed in DB")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
|
||||
"user_id": foreignUserID,
|
||||
"user_type": "member",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.UnsubscribeFromIssue(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
|
||||
issueID := createIssue(t)
|
||||
defer deleteIssue(t, issueID)
|
||||
|
||||
@@ -147,6 +147,13 @@ func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UU
|
||||
// so frontends can update immediately.
|
||||
func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) {
|
||||
task, err := s.Queries.CancelAgentTask(ctx, taskID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, err := s.Queries.GetAgentTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cancel task: %w", err)
|
||||
}
|
||||
return &existing, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cancel task: %w", err)
|
||||
}
|
||||
|
||||
@@ -48,12 +48,8 @@ func (s *LocalStorage) KeyFromURL(rawURL string) string {
|
||||
}
|
||||
|
||||
prefix := "/uploads/"
|
||||
if strings.HasPrefix(rawURL, prefix) {
|
||||
filename := strings.TrimPrefix(rawURL, prefix)
|
||||
if i := strings.LastIndex(filename, "/"); i >= 0 {
|
||||
return filename[i+1:]
|
||||
}
|
||||
return filename
|
||||
if idx := strings.Index(rawURL, prefix); idx >= 0 {
|
||||
return rawURL[idx+len(prefix):]
|
||||
}
|
||||
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
|
||||
return rawURL[i+1:]
|
||||
@@ -81,6 +77,9 @@ func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
|
||||
|
||||
func (s *LocalStorage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
|
||||
dest := filepath.Join(s.uploadDir, key)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return "", fmt.Errorf("local storage MkdirAll: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("local storage WriteFile: %w", err)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,8 @@ func TestLocalStorage_KeyFromURL(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{"local URL format", "/uploads/abc123.png", "abc123.png"},
|
||||
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
|
||||
{"local URL with subdir", "/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
|
||||
{"local URL with workspace prefix", "/uploads/workspaces/ws-123/abc.png", "workspaces/ws-123/abc.png"},
|
||||
{"just filename", "abc123.png", "abc123.png"},
|
||||
{"full path", "/some/path/to/file.pdf", "file.pdf"},
|
||||
}
|
||||
@@ -155,7 +156,7 @@ func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
|
||||
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "image.jpg"},
|
||||
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
|
||||
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
|
||||
}
|
||||
|
||||
|
||||
1
server/migrations/040_agent_custom_env.down.sql
Normal file
1
server/migrations/040_agent_custom_env.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE agent DROP COLUMN custom_env;
|
||||
5
server/migrations/040_agent_custom_env.up.sql
Normal file
5
server/migrations/040_agent_custom_env.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add custom_env column to agent table for user-configurable environment
|
||||
-- variables that get injected into the agent subprocess at launch time.
|
||||
-- Supports router/proxy (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL),
|
||||
-- Bedrock (CLAUDE_CODE_USE_BEDROCK + AWS creds), and Vertex AI modes.
|
||||
ALTER TABLE agent ADD COLUMN custom_env JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -82,13 +82,13 @@ type Result struct {
|
||||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, or hermes)
|
||||
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, hermes, or gemini)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes".
|
||||
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
@@ -105,8 +105,10 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
return &openclawBackend{cfg: cfg}, nil
|
||||
case "hermes":
|
||||
return &hermesBackend{cfg: cfg}, nil
|
||||
case "gemini":
|
||||
return &geminiBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes)", agentType)
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
args := buildClaudeArgs(opts)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
@@ -90,6 +91,12 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
var finalError string
|
||||
usage := make(map[string]TokenUsage)
|
||||
|
||||
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
|
||||
255
server/pkg/agent/gemini.go
Normal file
255
server/pkg/agent/gemini.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// geminiBackend implements Backend by spawning the Google Gemini CLI
|
||||
// with `--output-format stream-json` and parsing its NDJSON event stream.
|
||||
type geminiBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *geminiBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "gemini"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("gemini executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := buildGeminiArgs(prompt, opts)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
cmd.Env = buildEnv(b.cfg.Env)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("gemini stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[gemini:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start gemini: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("gemini started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
|
||||
startTime := time.Now()
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
usage := make(map[string]TokenUsage)
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var evt geminiStreamEvent
|
||||
if err := json.Unmarshal([]byte(line), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch evt.Type {
|
||||
case "init":
|
||||
sessionID = evt.SessionID
|
||||
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
|
||||
|
||||
case "message":
|
||||
if evt.Role == "assistant" && evt.Content != "" {
|
||||
output.WriteString(evt.Content)
|
||||
trySend(msgCh, Message{Type: MessageText, Content: evt.Content})
|
||||
}
|
||||
|
||||
case "tool_use":
|
||||
var params map[string]any
|
||||
if evt.Parameters != nil {
|
||||
_ = json.Unmarshal(evt.Parameters, ¶ms)
|
||||
}
|
||||
trySend(msgCh, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: evt.ToolName,
|
||||
CallID: evt.ToolID,
|
||||
Input: params,
|
||||
})
|
||||
|
||||
case "tool_result":
|
||||
trySend(msgCh, Message{
|
||||
Type: MessageToolResult,
|
||||
CallID: evt.ToolID,
|
||||
Output: evt.Output,
|
||||
})
|
||||
|
||||
case "error":
|
||||
trySend(msgCh, Message{
|
||||
Type: MessageError,
|
||||
Content: evt.Message,
|
||||
})
|
||||
|
||||
case "result":
|
||||
if evt.Status == "error" && evt.Error != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = evt.Error.Message
|
||||
}
|
||||
if evt.Stats != nil {
|
||||
b.accumulateUsage(usage, evt.Stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("gemini timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
} else if waitErr != nil && finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("gemini exited with error: %v", waitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("gemini finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: output.String(),
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: sessionID,
|
||||
Usage: usage,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// accumulateUsage extracts per-model token usage from Gemini's result stats.
|
||||
func (b *geminiBackend) accumulateUsage(usage map[string]TokenUsage, stats *geminiStreamStats) {
|
||||
for model, m := range stats.Models {
|
||||
u := usage[model]
|
||||
u.InputTokens += int64(m.InputTokens)
|
||||
u.OutputTokens += int64(m.OutputTokens)
|
||||
u.CacheReadTokens += int64(m.Cached)
|
||||
usage[model] = u
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gemini stream-json event types ──
|
||||
|
||||
type geminiStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// message fields
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Delta bool `json:"delta,omitempty"`
|
||||
|
||||
// tool_use fields
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolID string `json:"tool_id,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
|
||||
// tool_result fields
|
||||
Status string `json:"status,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
|
||||
// error fields
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// result fields
|
||||
Error *geminiStreamError `json:"error,omitempty"`
|
||||
Stats *geminiStreamStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type geminiStreamError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type geminiStreamStats struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
Models map[string]geminiModelStats `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
type geminiModelStats struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
Cached int `json:"cached"`
|
||||
}
|
||||
|
||||
// ── Arg builder ──
|
||||
|
||||
// buildGeminiArgs assembles the argv for a one-shot gemini invocation.
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// -p / --prompt non-interactive prompt (the user's task)
|
||||
// --yolo auto-approve all tool executions
|
||||
// -o stream-json streaming NDJSON output for live events
|
||||
// -m <model> optional model override
|
||||
// -r <session> resume a previous session (if provided)
|
||||
func buildGeminiArgs(prompt string, opts ExecOptions) []string {
|
||||
args := []string{
|
||||
"-p", prompt,
|
||||
"--yolo",
|
||||
"-o", "stream-json",
|
||||
}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "-m", opts.Model)
|
||||
}
|
||||
if opts.ResumeSessionID != "" {
|
||||
args = append(args, "-r", opts.ResumeSessionID)
|
||||
}
|
||||
return args
|
||||
}
|
||||
79
server/pkg/agent/gemini_test.go
Normal file
79
server/pkg/agent/gemini_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildGeminiArgsBaseline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildGeminiArgs("write a haiku", ExecOptions{})
|
||||
expected := []string{
|
||||
"-p", "write a haiku",
|
||||
"--yolo",
|
||||
"-o", "stream-json",
|
||||
}
|
||||
|
||||
if len(args) != len(expected) {
|
||||
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
|
||||
}
|
||||
for i, want := range expected {
|
||||
if args[i] != want {
|
||||
t.Fatalf("expected args[%d] = %q, got %q", i, want, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeminiArgsWithModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildGeminiArgs("hi", ExecOptions{Model: "gemini-2.5-pro"})
|
||||
|
||||
var foundModel bool
|
||||
for i, a := range args {
|
||||
if a == "-m" {
|
||||
if i+1 >= len(args) || args[i+1] != "gemini-2.5-pro" {
|
||||
t.Fatalf("expected -m followed by gemini-2.5-pro, got %v", args)
|
||||
}
|
||||
foundModel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundModel {
|
||||
t.Fatalf("expected -m flag when Model is set, got args=%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeminiArgsWithResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildGeminiArgs("hi", ExecOptions{ResumeSessionID: "3"})
|
||||
|
||||
var foundResume bool
|
||||
for i, a := range args {
|
||||
if a == "-r" {
|
||||
if i+1 >= len(args) || args[i+1] != "3" {
|
||||
t.Fatalf("expected -r followed by session id, got %v", args)
|
||||
}
|
||||
foundResume = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundResume {
|
||||
t.Fatalf("expected -r flag when ResumeSessionID is set, got args=%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeminiArgsOmitsModelWhenEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := buildGeminiArgs("hi", ExecOptions{})
|
||||
for _, a := range args {
|
||||
if a == "-m" {
|
||||
t.Fatalf("expected no -m flag when Model is empty, got args=%v", args)
|
||||
}
|
||||
if a == "-r" {
|
||||
t.Fatalf("expected no -r flag when ResumeSessionID is empty, got args=%v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,19 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano())
|
||||
}
|
||||
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
|
||||
}
|
||||
args = append(args, "--message", prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
@@ -67,6 +74,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
// Close stderr when the context is cancelled so the scanner unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stderr.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
@@ -129,22 +142,108 @@ type openclawEventResult struct {
|
||||
}
|
||||
|
||||
// processOutput reads the JSON output from openclaw --json stderr and returns
|
||||
// the parsed result. OpenClaw writes its JSON result to stderr, which may also
|
||||
// contain non-JSON log lines. We scan line-by-line so a final result line can
|
||||
// be recognized without waiting for the entire stderr stream to be buffered.
|
||||
// the parsed result. OpenClaw writes its JSON output to stderr, which may also
|
||||
// contain non-JSON log lines. The stream may contain:
|
||||
//
|
||||
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
|
||||
// "step_start", "step_finish") — emitted in real time as the agent works
|
||||
// - A final result JSON (with payloads + meta) — the legacy single-blob format
|
||||
//
|
||||
// We scan line-by-line, emitting messages as events arrive so streaming
|
||||
// consumers get real-time feedback instead of waiting for the final blob.
|
||||
func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclawEventResult {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
var usage TokenUsage
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
gotEvents := false // true if we parsed at least one streaming event or result
|
||||
|
||||
var rawLines []string
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if result, ok := tryParseOpenclawResult(line); ok {
|
||||
return b.buildOpenclawEventResult(result, ch)
|
||||
|
||||
// Try parsing as a streaming NDJSON event first.
|
||||
if event, ok := tryParseOpenclawEvent(line); ok {
|
||||
gotEvents = true
|
||||
if event.SessionID != "" {
|
||||
sessionID = event.SessionID
|
||||
}
|
||||
switch event.Type {
|
||||
case "text":
|
||||
if event.Text != "" {
|
||||
output.WriteString(event.Text)
|
||||
trySend(ch, Message{Type: MessageText, Content: event.Text})
|
||||
}
|
||||
case "tool_use":
|
||||
var input map[string]any
|
||||
if event.Input != nil {
|
||||
_ = json.Unmarshal(event.Input, &input)
|
||||
}
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: event.Tool,
|
||||
CallID: event.CallID,
|
||||
Input: input,
|
||||
})
|
||||
case "tool_result":
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: event.Tool,
|
||||
CallID: event.CallID,
|
||||
Output: event.Text,
|
||||
})
|
||||
case "error":
|
||||
errMsg := event.errorMessage()
|
||||
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
|
||||
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
||||
finalStatus = "failed"
|
||||
finalError = errMsg
|
||||
case "lifecycle":
|
||||
phase := event.Phase
|
||||
if phase == "error" || phase == "failed" || phase == "cancelled" {
|
||||
errMsg := event.errorMessage()
|
||||
b.cfg.Logger.Warn("openclaw lifecycle failure", "phase", phase, "error", errMsg)
|
||||
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
||||
finalStatus = "failed"
|
||||
finalError = errMsg
|
||||
}
|
||||
case "step_start":
|
||||
trySend(ch, Message{Type: MessageStatus, Status: "running"})
|
||||
case "step_finish":
|
||||
if event.Usage != nil {
|
||||
u := parseOpenclawUsage(event.Usage)
|
||||
usage.InputTokens += u.InputTokens
|
||||
usage.OutputTokens += u.OutputTokens
|
||||
usage.CacheReadTokens += u.CacheReadTokens
|
||||
usage.CacheWriteTokens += u.CacheWriteTokens
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try parsing as a final result blob (legacy format).
|
||||
if result, ok := tryParseOpenclawResult(line); ok {
|
||||
gotEvents = true
|
||||
res := b.buildOpenclawEventResult(result, ch, &output)
|
||||
if res.sessionID != "" {
|
||||
sessionID = res.sessionID
|
||||
}
|
||||
// Prefer usage from the final result if no streaming events reported it.
|
||||
u := res.usage
|
||||
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
|
||||
usage = u
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Not JSON — treat as log line.
|
||||
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
|
||||
rawLines = append(rawLines, line)
|
||||
}
|
||||
@@ -153,36 +252,82 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
|
||||
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
|
||||
if trimmed != "" {
|
||||
return openclawEventResult{status: "completed", output: trimmed}
|
||||
// If we got no events at all, fall back to raw output.
|
||||
if !gotEvents {
|
||||
// OpenClaw may output pretty-printed (multi-line) JSON. No single line
|
||||
// would parse, so try parsing the accumulated output as a whole.
|
||||
// Log lines may precede the JSON, so find the first '{' at line start.
|
||||
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
|
||||
if trimmed != "" {
|
||||
if result, ok := tryParseOpenclawResult(trimmed); ok {
|
||||
return b.buildOpenclawEventResult(result, ch, &output)
|
||||
}
|
||||
// Log lines may precede the JSON blob. Find the first line that
|
||||
// starts with '{' and try parsing from there.
|
||||
for i, line := range rawLines {
|
||||
if len(line) > 0 && line[0] == '{' {
|
||||
candidate := strings.TrimSpace(strings.Join(rawLines[i:], "\n"))
|
||||
if result, ok := tryParseOpenclawResult(candidate); ok {
|
||||
return b.buildOpenclawEventResult(result, ch, &output)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return openclawEventResult{status: "completed", output: trimmed}
|
||||
}
|
||||
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
|
||||
}
|
||||
|
||||
return openclawEventResult{
|
||||
status: finalStatus,
|
||||
errMsg: finalError,
|
||||
output: output.String(),
|
||||
sessionID: sessionID,
|
||||
usage: usage,
|
||||
}
|
||||
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
|
||||
}
|
||||
|
||||
// tryParseOpenclawEvent attempts to parse a line as a streaming NDJSON event.
|
||||
// Returns the event and true if the line is a valid event with a known type.
|
||||
func tryParseOpenclawEvent(line string) (openclawEvent, bool) {
|
||||
if len(line) == 0 || line[0] != '{' {
|
||||
return openclawEvent{}, false
|
||||
}
|
||||
var event openclawEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
return openclawEvent{}, false
|
||||
}
|
||||
if event.Type == "" {
|
||||
return openclawEvent{}, false
|
||||
}
|
||||
return event, true
|
||||
}
|
||||
|
||||
// tryParseOpenclawResult attempts to parse a line as a final result blob
|
||||
// (the legacy format with payloads + meta). Lines must start with '{' to be
|
||||
// considered — we no longer scan for braces at arbitrary positions, which
|
||||
// avoids false matches on log lines containing JSON fragments.
|
||||
func tryParseOpenclawResult(raw string) (openclawResult, bool) {
|
||||
// Try each '{' position until we find valid openclawResult JSON.
|
||||
// Earlier '{' chars may appear in log/error lines (e.g. raw_params={...}).
|
||||
var result openclawResult
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] != '{' {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw[i:]), &result); err == nil && (result.Payloads != nil || result.Meta.DurationMs > 0) {
|
||||
return result, true
|
||||
}
|
||||
if len(raw) == 0 || raw[0] != '{' {
|
||||
return openclawResult{}, false
|
||||
}
|
||||
return openclawResult{}, false
|
||||
var result openclawResult
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return openclawResult{}, false
|
||||
}
|
||||
if result.Payloads == nil && result.Meta.DurationMs == 0 {
|
||||
return openclawResult{}, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message) openclawEventResult {
|
||||
var output strings.Builder
|
||||
// buildOpenclawEventResult extracts text and metadata from a final result blob.
|
||||
// Text payloads are appended to the shared output builder and emitted to ch.
|
||||
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message, output *strings.Builder) openclawEventResult {
|
||||
for _, p := range result.Payloads {
|
||||
if p.Text != "" {
|
||||
if output.Len() > 0 {
|
||||
output.WriteString("\n")
|
||||
}
|
||||
output.WriteString(p.Text)
|
||||
trySend(ch, Message{Type: MessageText, Content: p.Text})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,17 +338,10 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
|
||||
sessionID = sid
|
||||
}
|
||||
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
|
||||
usage.InputTokens = openclawInt64(u, "input")
|
||||
usage.OutputTokens = openclawInt64(u, "output")
|
||||
usage.CacheReadTokens = openclawInt64(u, "cacheRead")
|
||||
usage.CacheWriteTokens = openclawInt64(u, "cacheWrite")
|
||||
usage = parseOpenclawUsage(u)
|
||||
}
|
||||
}
|
||||
|
||||
if output.Len() > 0 {
|
||||
trySend(ch, Message{Type: MessageText, Content: output.String()})
|
||||
}
|
||||
|
||||
return openclawEventResult{
|
||||
status: "completed",
|
||||
output: output.String(),
|
||||
@@ -212,6 +350,33 @@ func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch cha
|
||||
}
|
||||
}
|
||||
|
||||
// parseOpenclawUsage extracts token usage from a map, supporting multiple
|
||||
// field name conventions used by different OpenClaw versions and PaperClip:
|
||||
//
|
||||
// input / inputTokens / input_tokens
|
||||
// output / outputTokens / output_tokens
|
||||
// cacheRead / cachedInputTokens / cached_input_tokens / cache_read
|
||||
// cacheWrite / cacheCreationInputTokens / cache_creation_input_tokens / cache_write
|
||||
func parseOpenclawUsage(data map[string]any) TokenUsage {
|
||||
return TokenUsage{
|
||||
InputTokens: openclawInt64FirstOf(data, "input", "inputTokens", "input_tokens"),
|
||||
OutputTokens: openclawInt64FirstOf(data, "output", "outputTokens", "output_tokens"),
|
||||
CacheReadTokens: openclawInt64FirstOf(data, "cacheRead", "cachedInputTokens", "cached_input_tokens", "cache_read", "cache_read_input_tokens"),
|
||||
CacheWriteTokens: openclawInt64FirstOf(data, "cacheWrite", "cacheCreationInputTokens", "cache_creation_input_tokens", "cache_write"),
|
||||
}
|
||||
}
|
||||
|
||||
// openclawInt64FirstOf returns the first non-zero int64 value found under any
|
||||
// of the given keys. This supports field name variants across protocol versions.
|
||||
func openclawInt64FirstOf(data map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
if v := openclawInt64(data, key); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// openclawInt64 safely extracts an int64 from a JSON-decoded map value (which
|
||||
// may be float64 due to Go's JSON number handling).
|
||||
func openclawInt64(data map[string]any, key string) int64 {
|
||||
@@ -231,7 +396,73 @@ func openclawInt64(data map[string]any, key string) int64 {
|
||||
|
||||
// ── JSON types for `openclaw agent --json` output ──
|
||||
|
||||
// openclawResult represents the JSON output from `openclaw agent --json`.
|
||||
// openclawEvent represents a single streaming NDJSON event from openclaw --json.
|
||||
//
|
||||
// Event types:
|
||||
// - "text" — text output (text field)
|
||||
// - "tool_use" — tool invocation (tool, callId, input)
|
||||
// - "tool_result" — tool output (tool, callId, text)
|
||||
// - "error" — error (text, or structured error object)
|
||||
// - "lifecycle" — phase changes (phase: "error"/"failed"/"cancelled")
|
||||
// - "step_start" — agent step begins
|
||||
// - "step_finish" — agent step ends (usage)
|
||||
type openclawEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
CallID string `json:"callId,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Usage map[string]any `json:"usage,omitempty"`
|
||||
Phase string `json:"phase,omitempty"` // lifecycle event phase
|
||||
Error *openclawError `json:"error,omitempty"` // structured error object
|
||||
Message string `json:"message,omitempty"` // alternative error message field
|
||||
}
|
||||
|
||||
// errorMessage extracts a human-readable error message from the event,
|
||||
// checking multiple fields: structured error object, text, message, or fallback.
|
||||
func (e openclawEvent) errorMessage() string {
|
||||
if e.Error != nil {
|
||||
if msg := e.Error.message(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
if e.Text != "" {
|
||||
return e.Text
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return "unknown openclaw error"
|
||||
}
|
||||
|
||||
// openclawError represents a structured error in an openclaw event,
|
||||
// compatible with PaperClip's error format (name + data.message).
|
||||
type openclawError struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Data *openclawErrorData `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (e *openclawError) message() string {
|
||||
if e.Data != nil && e.Data.Message != "" {
|
||||
return e.Data.Message
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
if e.Name != "" {
|
||||
return e.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type openclawErrorData struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// openclawResult represents the final JSON output from `openclaw agent --json`
|
||||
// (the legacy single-blob format with payloads + meta).
|
||||
type openclawResult struct {
|
||||
Payloads []openclawPayload `json:"payloads"`
|
||||
Meta openclawMeta `json:"meta"`
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestNewReturnsOpenclawBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── processOutput tests ──
|
||||
// ── Legacy result format tests (processOutput with final JSON blob) ──
|
||||
|
||||
func TestOpenclawProcessOutputHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -90,11 +90,24 @@ func TestOpenclawProcessOutputMultiplePayloads(t *testing.T) {
|
||||
|
||||
res := b.processOutput(strings.NewReader(string(data)), ch)
|
||||
|
||||
if res.output != "First\nSecond" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "First\nSecond")
|
||||
if res.output != "FirstSecond" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "FirstSecond")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 text messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Content != "First" {
|
||||
t.Errorf("msg[0]: got %q, want %q", msgs[0].Content, "First")
|
||||
}
|
||||
if msgs[1].Content != "Second" {
|
||||
t.Errorf("msg[1]: got %q, want %q", msgs[1].Content, "Second")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawProcessOutputEmptyPayloads(t *testing.T) {
|
||||
@@ -238,7 +251,8 @@ func TestOpenclawProcessOutputWithBracesInLogLines(t *testing.T) {
|
||||
Meta: openclawMeta{DurationMs: 500},
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
// Simulate error line containing braces before the real JSON (the exact bug scenario)
|
||||
// Log line with braces should NOT be parsed as JSON — only lines starting
|
||||
// with '{' are considered. The result blob on its own line is still parsed.
|
||||
input := `[tools] exec failed: complex interpreter invocation detected. raw_params={"command":"echo hello"}` + "\n" + string(data)
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
@@ -253,6 +267,627 @@ func TestOpenclawProcessOutputWithBracesInLogLines(t *testing.T) {
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawResultBlobWithLeadingPrefixRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// A line with a prefix before the JSON should NOT be parsed as a result.
|
||||
// This tests that the hardened parser rejects non-'{'-starting lines.
|
||||
result := openclawResult{
|
||||
Payloads: []openclawPayload{{Text: "Should not match"}},
|
||||
Meta: openclawMeta{DurationMs: 500},
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
input := "some prefix " + string(data)
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
// Should fall back to raw output since the JSON has a prefix.
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.output != input {
|
||||
t.Errorf("output: got %q, want raw input back", res.output)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ── Streaming NDJSON event tests ──
|
||||
|
||||
func TestOpenclawStreamingTextEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"text","text":"Hello "}`,
|
||||
`{"type":"text","text":"world"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.output != "Hello world" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "Hello world")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Type != MessageText || msgs[0].Content != "Hello " {
|
||||
t.Errorf("msg[0]: type=%s content=%q", msgs[0].Type, msgs[0].Content)
|
||||
}
|
||||
if msgs[1].Type != MessageText || msgs[1].Content != "world" {
|
||||
t.Errorf("msg[1]: type=%s content=%q", msgs[1].Type, msgs[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawStreamingToolUseEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"tool_use","tool":"bash","callId":"call_1","input":{"command":"ls -la"}}`,
|
||||
`{"type":"tool_result","tool":"bash","callId":"call_1","text":"total 42\ndrwxr-xr-x"}`,
|
||||
`{"type":"text","text":"Listed files."}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
||||
}
|
||||
|
||||
// tool_use
|
||||
if msgs[0].Type != MessageToolUse {
|
||||
t.Errorf("msg[0] type: got %s, want tool-use", msgs[0].Type)
|
||||
}
|
||||
if msgs[0].Tool != "bash" {
|
||||
t.Errorf("msg[0] tool: got %q, want %q", msgs[0].Tool, "bash")
|
||||
}
|
||||
if msgs[0].CallID != "call_1" {
|
||||
t.Errorf("msg[0] callID: got %q, want %q", msgs[0].CallID, "call_1")
|
||||
}
|
||||
if msgs[0].Input["command"] != "ls -la" {
|
||||
t.Errorf("msg[0] input: got %v", msgs[0].Input)
|
||||
}
|
||||
|
||||
// tool_result
|
||||
if msgs[1].Type != MessageToolResult {
|
||||
t.Errorf("msg[1] type: got %s, want tool-result", msgs[1].Type)
|
||||
}
|
||||
if msgs[1].CallID != "call_1" {
|
||||
t.Errorf("msg[1] callID: got %q", msgs[1].CallID)
|
||||
}
|
||||
if msgs[1].Output != "total 42\ndrwxr-xr-x" {
|
||||
t.Errorf("msg[1] output: got %q", msgs[1].Output)
|
||||
}
|
||||
|
||||
// text
|
||||
if msgs[2].Type != MessageText || msgs[2].Content != "Listed files." {
|
||||
t.Errorf("msg[2]: type=%s content=%q", msgs[2].Type, msgs[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawStreamingErrorEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"text","text":"Starting..."}`,
|
||||
`{"type":"error","text":"model not found: gpt-99"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
if res.errMsg != "model not found: gpt-99" {
|
||||
t.Errorf("errMsg: got %q", res.errMsg)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[1].Type != MessageError {
|
||||
t.Errorf("msg[1] type: got %s, want error", msgs[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawStreamingStepFinishUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"step_start"}`,
|
||||
`{"type":"text","text":"Done"}`,
|
||||
`{"type":"step_finish","usage":{"input":200,"output":100,"cacheRead":50,"cacheWrite":25}}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.usage.InputTokens != 200 {
|
||||
t.Errorf("input tokens: got %d, want 200", res.usage.InputTokens)
|
||||
}
|
||||
if res.usage.OutputTokens != 100 {
|
||||
t.Errorf("output tokens: got %d, want 100", res.usage.OutputTokens)
|
||||
}
|
||||
if res.usage.CacheReadTokens != 50 {
|
||||
t.Errorf("cache read: got %d, want 50", res.usage.CacheReadTokens)
|
||||
}
|
||||
if res.usage.CacheWriteTokens != 25 {
|
||||
t.Errorf("cache write: got %d, want 25", res.usage.CacheWriteTokens)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawStreamingSessionID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"text","text":"Hi","sessionId":"ses_stream_123"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.sessionID != "ses_stream_123" {
|
||||
t.Errorf("sessionID: got %q, want %q", res.sessionID, "ses_stream_123")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawStreamingMixedWithLogLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
"[info] initializing agent...",
|
||||
`{"type":"text","text":"Hello"}`,
|
||||
"[debug] tool exec completed",
|
||||
`{"type":"text","text":" world"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.output != "Hello world" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "Hello world")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 text messages, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle event tests ──
|
||||
|
||||
func TestOpenclawLifecycleErrorPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"text","text":"Working..."}`,
|
||||
`{"type":"lifecycle","phase":"error","text":"agent crashed unexpectedly"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
if res.errMsg != "agent crashed unexpectedly" {
|
||||
t.Errorf("errMsg: got %q", res.errMsg)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[1].Type != MessageError {
|
||||
t.Errorf("msg[1] type: got %s, want error", msgs[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawLifecycleFailedPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"lifecycle","phase":"failed","message":"timeout exceeded"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
if res.errMsg != "timeout exceeded" {
|
||||
t.Errorf("errMsg: got %q, want %q", res.errMsg, "timeout exceeded")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawLifecycleCancelledPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"lifecycle","phase":"cancelled"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
// With no text/message/error, should get the default.
|
||||
if res.errMsg != "unknown openclaw error" {
|
||||
t.Errorf("errMsg: got %q", res.errMsg)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawLifecycleRunningPhaseIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"lifecycle","phase":"running"}`,
|
||||
`{"type":"text","text":"Hello"}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ── Structured error tests ──
|
||||
|
||||
func TestOpenclawStructuredErrorObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"error","error":{"name":"ModelNotFoundError","data":{"message":"model gpt-99 not available"}}}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
if res.errMsg != "model gpt-99 not available" {
|
||||
t.Errorf("errMsg: got %q, want %q", res.errMsg, "model gpt-99 not available")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawStructuredErrorNameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"error","error":{"name":"AuthenticationError"}}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.errMsg != "AuthenticationError" {
|
||||
t.Errorf("errMsg: got %q, want %q", res.errMsg, "AuthenticationError")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawStructuredErrorMessageField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"error","error":{"message":"rate limit exceeded"}}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.errMsg != "rate limit exceeded" {
|
||||
t.Errorf("errMsg: got %q, want %q", res.errMsg, "rate limit exceeded")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ── Usage field name variant tests ──
|
||||
|
||||
func TestOpenclawUsageAlternativeFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test PaperClip-style field names (inputTokens, outputTokens, etc.)
|
||||
data := map[string]any{
|
||||
"inputTokens": float64(500),
|
||||
"outputTokens": float64(200),
|
||||
"cachedInputTokens": float64(100),
|
||||
}
|
||||
usage := parseOpenclawUsage(data)
|
||||
|
||||
if usage.InputTokens != 500 {
|
||||
t.Errorf("InputTokens: got %d, want 500", usage.InputTokens)
|
||||
}
|
||||
if usage.OutputTokens != 200 {
|
||||
t.Errorf("OutputTokens: got %d, want 200", usage.OutputTokens)
|
||||
}
|
||||
if usage.CacheReadTokens != 100 {
|
||||
t.Errorf("CacheReadTokens: got %d, want 100", usage.CacheReadTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawUsageSnakeCaseFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test snake_case field names (Anthropic API style)
|
||||
data := map[string]any{
|
||||
"input_tokens": float64(300),
|
||||
"output_tokens": float64(150),
|
||||
"cache_read_input_tokens": float64(80),
|
||||
"cache_creation_input_tokens": float64(40),
|
||||
}
|
||||
usage := parseOpenclawUsage(data)
|
||||
|
||||
if usage.InputTokens != 300 {
|
||||
t.Errorf("InputTokens: got %d, want 300", usage.InputTokens)
|
||||
}
|
||||
if usage.OutputTokens != 150 {
|
||||
t.Errorf("OutputTokens: got %d, want 150", usage.OutputTokens)
|
||||
}
|
||||
if usage.CacheReadTokens != 80 {
|
||||
t.Errorf("CacheReadTokens: got %d, want 80", usage.CacheReadTokens)
|
||||
}
|
||||
if usage.CacheWriteTokens != 40 {
|
||||
t.Errorf("CacheWriteTokens: got %d, want 40", usage.CacheWriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawUsageOriginalFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test the original short field names (input, output, cacheRead, cacheWrite)
|
||||
data := map[string]any{
|
||||
"input": float64(100),
|
||||
"output": float64(50),
|
||||
"cacheRead": float64(10),
|
||||
"cacheWrite": float64(5),
|
||||
}
|
||||
usage := parseOpenclawUsage(data)
|
||||
|
||||
if usage.InputTokens != 100 {
|
||||
t.Errorf("InputTokens: got %d, want 100", usage.InputTokens)
|
||||
}
|
||||
if usage.OutputTokens != 50 {
|
||||
t.Errorf("OutputTokens: got %d, want 50", usage.OutputTokens)
|
||||
}
|
||||
if usage.CacheReadTokens != 10 {
|
||||
t.Errorf("CacheReadTokens: got %d, want 10", usage.CacheReadTokens)
|
||||
}
|
||||
if usage.CacheWriteTokens != 5 {
|
||||
t.Errorf("CacheWriteTokens: got %d, want 5", usage.CacheWriteTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawUsageAccumulationAcrossSteps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := []string{
|
||||
`{"type":"step_finish","usage":{"inputTokens":100,"outputTokens":50}}`,
|
||||
`{"type":"step_finish","usage":{"inputTokens":200,"outputTokens":80,"cachedInputTokens":60}}`,
|
||||
}
|
||||
input := strings.Join(lines, "\n")
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.usage.InputTokens != 300 {
|
||||
t.Errorf("InputTokens: got %d, want 300", res.usage.InputTokens)
|
||||
}
|
||||
if res.usage.OutputTokens != 130 {
|
||||
t.Errorf("OutputTokens: got %d, want 130", res.usage.OutputTokens)
|
||||
}
|
||||
if res.usage.CacheReadTokens != 60 {
|
||||
t.Errorf("CacheReadTokens: got %d, want 60", res.usage.CacheReadTokens)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawUsageFinalResultAlternativeFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
result := openclawResult{
|
||||
Payloads: []openclawPayload{{Text: "Done"}},
|
||||
Meta: openclawMeta{
|
||||
DurationMs: 1000,
|
||||
AgentMeta: map[string]any{
|
||||
"usage": map[string]any{
|
||||
"inputTokens": float64(400),
|
||||
"outputTokens": float64(180),
|
||||
"cachedInputTokens": float64(90),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
|
||||
res := b.processOutput(strings.NewReader(string(data)), ch)
|
||||
|
||||
if res.usage.InputTokens != 400 {
|
||||
t.Errorf("InputTokens: got %d, want 400", res.usage.InputTokens)
|
||||
}
|
||||
if res.usage.OutputTokens != 180 {
|
||||
t.Errorf("OutputTokens: got %d, want 180", res.usage.OutputTokens)
|
||||
}
|
||||
if res.usage.CacheReadTokens != 90 {
|
||||
t.Errorf("CacheReadTokens: got %d, want 90", res.usage.CacheReadTokens)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawProcessOutputMultilineJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
result := openclawResult{
|
||||
Payloads: []openclawPayload{{Text: "Pretty printed response"}},
|
||||
Meta: openclawMeta{
|
||||
DurationMs: 4764,
|
||||
AgentMeta: map[string]any{
|
||||
"sessionId": "test-session",
|
||||
"usage": map[string]any{
|
||||
"input": float64(100),
|
||||
"output": float64(34),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Marshal with indentation to simulate openclaw's pretty-printed output.
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
|
||||
res := b.processOutput(strings.NewReader(string(data)), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.output != "Pretty printed response" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "Pretty printed response")
|
||||
}
|
||||
if res.sessionID != "test-session" {
|
||||
t.Errorf("sessionID: got %q, want %q", res.sessionID, "test-session")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Content != "Pretty printed response" {
|
||||
t.Errorf("expected 1 text message with content, got %d msgs", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawProcessOutputMultilineJSONWithLeadingLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
result := openclawResult{
|
||||
Payloads: []openclawPayload{{Text: "Answer after logs"}},
|
||||
Meta: openclawMeta{DurationMs: 100},
|
||||
}
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
input := "some startup log\nanother log line\n" + string(data)
|
||||
|
||||
res := b.processOutput(strings.NewReader(input), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.output != "Answer after logs" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "Answer after logs")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ── openclawInt64 tests ──
|
||||
|
||||
func TestOpenclawInt64Float(t *testing.T) {
|
||||
|
||||
@@ -48,6 +48,7 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
args = append(args, prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
@@ -74,6 +75,12 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
// Close stdout when the context is cancelled so the scanner unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`
|
||||
|
||||
type ArchiveAgentParams struct {
|
||||
@@ -43,6 +43,7 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -213,9 +214,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
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
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
|
||||
instructions, custom_env
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
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
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
@@ -230,6 +231,7 @@ type CreateAgentParams struct {
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
|
||||
@@ -245,6 +247,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.OwnerID,
|
||||
arg.Instructions,
|
||||
arg.CustomEnv,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -265,6 +268,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -394,7 +398,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 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 FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -419,12 +423,13 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
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 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 FROM agent
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -454,6 +459,7 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -651,7 +657,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 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 FROM agent
|
||||
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -683,6 +689,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -695,7 +702,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 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 FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -727,6 +734,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -829,7 +837,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
|
||||
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
|
||||
`
|
||||
|
||||
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
@@ -853,6 +861,7 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -901,9 +910,10 @@ UPDATE agent SET
|
||||
status = COALESCE($9, status),
|
||||
max_concurrent_tasks = COALESCE($10, max_concurrent_tasks),
|
||||
instructions = COALESCE($11, instructions),
|
||||
custom_env = COALESCE($12, custom_env),
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
@@ -918,6 +928,7 @@ type UpdateAgentParams struct {
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
Instructions pgtype.Text `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
|
||||
@@ -933,6 +944,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
arg.Status,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.Instructions,
|
||||
arg.CustomEnv,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -953,6 +965,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -960,7 +973,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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
@@ -989,6 +1002,7 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type Agent struct {
|
||||
Instructions string `json:"instructions"`
|
||||
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
}
|
||||
|
||||
type AgentRuntime struct {
|
||||
|
||||
@@ -40,6 +40,42 @@ func (q *Queries) DeleteArchivedAgentsByRuntime(ctx context.Context, runtimeID p
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteStaleOfflineRuntimes = `-- name: DeleteStaleOfflineRuntimes :many
|
||||
DELETE FROM agent_runtime
|
||||
WHERE status = 'offline'
|
||||
AND last_seen_at < now() - make_interval(secs => $1::double precision)
|
||||
AND id NOT IN (SELECT DISTINCT runtime_id FROM agent)
|
||||
RETURNING id, workspace_id
|
||||
`
|
||||
|
||||
type DeleteStaleOfflineRuntimesRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// Deletes runtimes that have been offline for longer than the TTL and have
|
||||
// no agents bound (active or archived). The FK constraint on agent.runtime_id
|
||||
// is ON DELETE RESTRICT, so we must exclude all agent references.
|
||||
func (q *Queries) DeleteStaleOfflineRuntimes(ctx context.Context, staleSeconds float64) ([]DeleteStaleOfflineRuntimesRow, error) {
|
||||
rows, err := q.db.Query(ctx, deleteStaleOfflineRuntimes, staleSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []DeleteStaleOfflineRuntimesRow{}
|
||||
for rows.Next() {
|
||||
var i DeleteStaleOfflineRuntimesRow
|
||||
if err := rows.Scan(&i.ID, &i.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const failTasksForOfflineRuntimes = `-- name: FailTasksForOfflineRuntimes :many
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'failed', completed_at = now(), error = 'runtime went offline'
|
||||
@@ -253,6 +289,48 @@ func (q *Queries) MarkStaleRuntimesOffline(ctx context.Context, staleSeconds flo
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const migrateAgentsToRuntime = `-- name: MigrateAgentsToRuntime :execrows
|
||||
UPDATE agent
|
||||
SET runtime_id = $1
|
||||
WHERE runtime_id IN (
|
||||
SELECT ar.id FROM agent_runtime ar
|
||||
WHERE ar.workspace_id = $2
|
||||
AND ar.provider = $3
|
||||
AND ar.owner_id = $4
|
||||
AND ar.id != $1
|
||||
AND ar.status = 'offline'
|
||||
AND ar.daemon_id LIKE $5 || '-%'
|
||||
)
|
||||
`
|
||||
|
||||
type MigrateAgentsToRuntimeParams struct {
|
||||
NewRuntimeID pgtype.UUID `json:"new_runtime_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Provider string `json:"provider"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
DaemonIDPrefix pgtype.Text `json:"daemon_id_prefix"`
|
||||
}
|
||||
|
||||
// Migrates agents from stale offline runtimes to the newly registered runtime.
|
||||
// Only migrates from runtimes that match the same workspace, provider, owner,
|
||||
// AND whose daemon_id starts with the current daemon_id followed by '-'.
|
||||
// This scopes migration to old profile-suffixed runtimes from the same machine
|
||||
// (e.g. "MacBook-staging" matches daemon_id_prefix "MacBook") without touching
|
||||
// runtimes from other machines belonging to the same user.
|
||||
func (q *Queries) MigrateAgentsToRuntime(ctx context.Context, arg MigrateAgentsToRuntimeParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, migrateAgentsToRuntime,
|
||||
arg.NewRuntimeID,
|
||||
arg.WorkspaceID,
|
||||
arg.Provider,
|
||||
arg.OwnerID,
|
||||
arg.DaemonIDPrefix,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const setAgentRuntimeOffline = `-- name: SetAgentRuntimeOffline :exec
|
||||
UPDATE agent_runtime
|
||||
SET status = 'offline', updated_at = now()
|
||||
|
||||
@@ -101,11 +101,11 @@ ORDER BY date DESC
|
||||
|
||||
type ListRuntimeUsageParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Since pgtype.Date `json:"since"`
|
||||
Date pgtype.Date `json:"date"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
|
||||
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
|
||||
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
instructions, custom_env
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
@@ -36,6 +36,7 @@ UPDATE agent SET
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
|
||||
instructions = COALESCE(sqlc.narg('instructions'), instructions),
|
||||
custom_env = COALESCE(sqlc.narg('custom_env'), custom_env),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
@@ -78,3 +78,32 @@ SELECT count(*) FROM agent WHERE runtime_id = $1 AND archived_at IS NULL;
|
||||
|
||||
-- name: DeleteArchivedAgentsByRuntime :exec
|
||||
DELETE FROM agent WHERE runtime_id = $1 AND archived_at IS NOT NULL;
|
||||
|
||||
-- name: MigrateAgentsToRuntime :execrows
|
||||
-- Migrates agents from stale offline runtimes to the newly registered runtime.
|
||||
-- Only migrates from runtimes that match the same workspace, provider, owner,
|
||||
-- AND whose daemon_id starts with the current daemon_id followed by '-'.
|
||||
-- This scopes migration to old profile-suffixed runtimes from the same machine
|
||||
-- (e.g. "MacBook-staging" matches daemon_id_prefix "MacBook") without touching
|
||||
-- runtimes from other machines belonging to the same user.
|
||||
UPDATE agent
|
||||
SET runtime_id = @new_runtime_id
|
||||
WHERE runtime_id IN (
|
||||
SELECT ar.id FROM agent_runtime ar
|
||||
WHERE ar.workspace_id = @workspace_id
|
||||
AND ar.provider = @provider
|
||||
AND ar.owner_id = @owner_id
|
||||
AND ar.id != @new_runtime_id
|
||||
AND ar.status = 'offline'
|
||||
AND ar.daemon_id LIKE @daemon_id_prefix || '-%'
|
||||
);
|
||||
|
||||
-- name: DeleteStaleOfflineRuntimes :many
|
||||
-- Deletes runtimes that have been offline for longer than the TTL and have
|
||||
-- no agents bound (active or archived). The FK constraint on agent.runtime_id
|
||||
-- is ON DELETE RESTRICT, so we must exclude all agent references.
|
||||
DELETE FROM agent_runtime
|
||||
WHERE status = 'offline'
|
||||
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision)
|
||||
AND id NOT IN (SELECT DISTINCT runtime_id FROM agent)
|
||||
RETURNING id, workspace_id;
|
||||
|
||||
Reference in New Issue
Block a user