Compare commits

..

19 Commits

Author SHA1 Message Date
jayavibhavnk
6b1ae10f50 fix(test): add missing useFileDropZone and FileDropOverlay to editor mock
create-issue.tsx imports useFileDropZone and FileDropOverlay from
../editor, but the vi.mock("../editor") in create-issue.test.tsx did
not include them. Vitest aborts with "No useFileDropZone export is
defined on the mock" before the test even runs.

Added both missing exports to the mock — FileDropOverlay renders null
and useFileDropZone returns a stable stub — matching the pattern used
in issue-detail.test.tsx.
2026-04-12 22:44:40 +08:00
jayavibhavnk
67212b0bef fix(server): fix ListRuntimeUsage to filter by date range instead of row count
GetRuntimeUsage accepted a ?days=N query parameter intended to return N
days of history, but the underlying ListRuntimeUsage query used it as a
raw LIMIT on rows. When a runtime uses multiple models (e.g. claude-opus
and claude-sonnet), each day produces multiple rows, so LIMIT 90 would
only return ~30 days of data instead of 90, silently truncating history.

Fix: replace the LIMIT clause with AND date >= $2 in the SQL query,
update ListRuntimeUsageParams to hold a Since pgtype.Date instead of a
Limit int32, and compute the date cutoff in the handler using
time.Now().AddDate(0, 0, -days). The generated Go code and the handler
are updated consistently.

Closes #731

Made-with: Cursor
2026-04-12 22:42:56 +08:00
Jiayuan Zhang
c7e0863419 fix(auth): preserve last workspace ID across re-login (#772)
The logout handler was clearing `multica_workspace_id` from storage,
so re-login always defaulted to the first workspace. The workspace ID
is a user preference, not session-sensitive data — keep it so both
web and desktop restore the correct workspace after re-authentication.

Also pass `lastWorkspaceId` in the desktop login page, which was
previously missing.
2026-04-12 21:33:19 +08:00
Jiayuan Zhang
d7c83bc285 fix(sanitize): preserve code blocks and inline code from HTML entity escaping (#774)
Bluemonday operates on raw text, so characters like && and <> inside
markdown code blocks/inline code were being HTML-escaped (e.g. && → &amp;&amp;),
causing them to render incorrectly in the frontend.

Now extracts fenced code blocks and inline code spans before sanitization,
runs bluemonday on the remaining content, then restores the code verbatim.
2026-04-12 21:32:35 +08:00
Jiayuan Zhang
4285549381 fix(views): navigate to issue in same tab instead of opening new tab (#773)
Issue mention clicks now use push() for same-tab navigation, matching
AppLink behavior. Cmd/Ctrl+Click still opens in a new tab on desktop.
2026-04-12 17:29:54 +08:00
Bohan Jiang
9ed80120e0 fix(views): add missing useFileDropZone and FileDropOverlay mocks in create-issue test (#768)
The create-issue modal started importing useFileDropZone and FileDropOverlay
from the editor module, but the test mock was not updated to include them,
causing CI to fail.
2026-04-12 15:18:15 +08:00
Manish Chauhan
ec586ebc25 fix(pins): scope cache by user and fix sidebar pin action (#664) 2026-04-12 15:02:20 +08:00
Bohan Jiang
ea8cb18f9e Merge pull request #639 from jyf2100/agent/agent/e7cb5f8c
test(web): cover issue creation flow regressions
2026-04-12 14:17:47 +08:00
Bohan Jiang
d011039c58 fix(sweeper): add error logging and dedup for issue reset (#762)
- Log a warning when HasActiveTaskForIssue fails, matching the existing
  pattern for UpdateIssueStatus errors. Silent failures here make
  debugging DB issues unnecessarily difficult.
- Track processed issues to skip redundant GetIssue + HasActiveTaskForIssue
  queries when multiple tasks for the same issue are swept in one cycle.
2026-04-12 14:13:07 +08:00
Gabriel Amazonas
471d4a6838 Update required AI agent CLI list in SELF_HOSTING.md (#734)
Added OpenClaw and OpenCode to the list of required AI agent CLIs.
2026-04-12 14:09:57 +08:00
pradeep7127
bd42552854 fix(sweeper): reset in_progress issues to todo after stale task sweep (#747)
fix(sweeper): reset in_progress issues to todo after stale task sweep
2026-04-12 14:08:54 +08:00
Bohan Jiang
31eeb00b59 fix(storage): clean up variable shadowing and dead code (#761)
- Rename `filepath` local var to `dest` in LocalStorage.Upload to avoid
  shadowing the path/filepath package import
- Remove unused detectContentType and overrideContentType functions from
  util.go (no longer needed after ServeFile switched to http.ServeFile)
2026-04-12 14:06:46 +08:00
Antar Das
d32c419b6d feat(storage): add local file storage fallback (#710)
* feat(storage): add local file storage fallback

- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions

* ♻️ refactor(storage): improve path handling and file serving

switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.
2026-04-12 14:04:22 +08:00
Jiayuan Zhang
f31a322978 chore: add issue templates and improve PR template (#759)
* chore: add issue templates and improve PR template

Add GitHub issue templates (bug report, feature request) using YAML
forms, referencing hermes-agent's template structure. Update the PR
template with clearer sections for changes made, related issues, and
a more comprehensive checklist.

* chore: add AI disclosure section to PR template

Since most PRs are now authored or co-authored by AI coding tools,
add a dedicated AI Disclosure section to the PR template. Includes
authorship type, tool used, and a human review checklist to ensure
AI-generated code is properly reviewed before merge.

* chore: simplify AI disclosure to focus on prompt sharing

Remove the review-status checklist — it was too heavy and users won't
actually do it. Instead focus on what's useful: which AI tool was used
and what prompt/approach produced the code, so the team can learn from
each other's AI workflows.

* chore: simplify issue templates to lower submission friction

Bug report: just what happened + steps to reproduce (required),
plus an optional context field for logs/env.

Feature request: just what you want and why (required),
plus an optional proposed solution.

Removed all dropdowns, environment fields, checkboxes, and
other fields that discourage users from filing issues.

* chore: add screenshots section to issue templates

Add optional screenshots field to both bug report and feature request
templates so users can attach images for richer context.
2026-04-12 13:58:18 +08:00
Jiayuan Zhang
5bae3368d7 feat(landing): add install command copy block to hero section (#743)
Adds a terminal-style one-click copy block below the CTA buttons showing
the curl install command, with a copy-to-clipboard button that shows a
checkmark on success.
2026-04-12 02:42:05 +08:00
Jiayuan Zhang
f100b5b707 fix(auth): graceful email degradation for self-hosting (#742)
* fix(auth): log email send errors and gracefully degrade in non-production

In non-production environments (APP_ENV != "production"), if sending the
verification code email fails, log the error as a warning and still return
success. This lets self-hosting users log in with the master code (888888)
even when their Resend configuration is incomplete (e.g. unverified from-domain).

In production, the behavior is unchanged — email failures return 500.

Also adds guidance in .env.example about RESEND_FROM_EMAIL for self-hosters.

Closes #723

* fix(auth): remove APP_ENV degradation, keep error logging only

Remove the APP_ENV-based graceful degradation for email send failures
— it's risky if users forget to set APP_ENV=production. Instead, always
return 500 on email failure (safe for production) and rely on the error
log (slog.Error) with the actual Resend error for debugging.

Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
2026-04-12 02:30:01 +08:00
Jiayuan Zhang
701399536f feat(cli): enhance version command with JSON output and build info (#740)
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:18:08 +08:00
Jiayuan Zhang
4ca607f888 chore: remove Apache 2.0 license badge from READMEs (#739) 2026-04-12 02:11:45 +08:00
roc
a35f71f65d test(web): cover issue creation flow regressions 2026-04-10 15:52:43 +08:00
42 changed files with 1233 additions and 164 deletions

View File

@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -40,6 +42,10 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

39
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,26 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,34 +1,56 @@
## What
## What does this PR do?
<!-- What does this PR do? Keep it to 1-3 sentences. -->
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
- [ ] Other (describe below)
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## 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
## AI Disclosure (optional)
## AI Disclosure
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

View File

@@ -18,7 +18,6 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)

View File

@@ -18,7 +18,6 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)

View File

@@ -77,6 +77,8 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
### b) One-command setup

View File

@@ -2,6 +2,8 @@ import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
@@ -11,6 +13,7 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -52,6 +53,8 @@ export function LandingHero() {
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
@@ -87,6 +90,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

13
e2e/env.ts Normal file
View File

@@ -0,0 +1,13 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { config } from "dotenv";
const envCandidates = [".env.worktree", ".env"];
for (const filename of envCandidates) {
const path = resolve(process.cwd(), filename);
if (existsSync(path)) {
config({ path });
break;
}
}

View File

@@ -4,6 +4,7 @@
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
@@ -21,39 +22,43 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
// Keep each E2E login isolated so previous test runs do not trip the
// per-email send-code rate limit.
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
// Step 2: Read code from database
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email]
[email],
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
body: JSON.stringify({ email, code: result.rows[0].code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
}
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
@@ -64,6 +69,8 @@ export class TestApiClient {
});
}
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
return data;
} finally {
await client.end();

View File

@@ -11,11 +11,14 @@ test.describe("Issues", () => {
});
test.afterEach(async () => {
await api.cleanup();
if (api) {
await api.cleanup();
}
});
test("issues page loads with board view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await expect(page.locator("text=Backlog")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
await expect(page.getByText(title)).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
await page.click("text=New Issue");
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
const title = "E2E Created " + Date.now();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await titleInput.fill(title);
await page.getByRole("button", { name: "Create Issue" }).click();
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("region", { name: /Notifications/ }).getByText(title),
).toBeVisible();
await page.getByRole("button", { name: "View issue" }).click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
});
test("can navigate to issue detail page", async ({ page }) => {
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
).toBeVisible();
});
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
test("can dismiss issue creation", async ({ page }) => {
await page.getByRole("button", { name: "New Issue" }).click();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await page.click("text=Cancel");
await page.keyboard.press("Escape");
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
await expect(titleInput).not.toBeVisible();
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
});
});

View File

@@ -72,7 +72,6 @@ export function createAuthStore(options: AuthStoreOptions) {
logout: () => {
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useAuthStore } from "../auth";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
@@ -7,16 +8,17 @@ import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -24,22 +26,23 @@ export function useCreatePin() {
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -47,19 +50,20 @@ export function useDeletePin() {
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
});
}

View File

@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
};
export function pinListOptions(wsId: string) {
export function pinListOptions(wsId: string, userId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId),
queryKey: pinKeys.list(wsId, userId),
queryFn: () => api.listPins(),
});
}

View File

@@ -102,7 +102,8 @@ export function useRealtimeSync(
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;

View File

@@ -53,7 +53,7 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
@@ -61,11 +61,13 @@ function IssueMention({
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
} else {
window.open(issuePath, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
}
return;
}
push(issuePath);
};
const cardClass =

View File

@@ -86,7 +86,7 @@ function urlTransform(url: string): string {
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const path = `/issues/${issueId}`;
return (
<span
@@ -94,11 +94,13 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(path, label);
} else {
window.open(path, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />

View File

@@ -94,7 +94,10 @@ vi.mock("../../editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
ContentEditor: forwardRef(function MockContentEditor(
{ defaultValue, onUpdate, placeholder }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -116,7 +119,10 @@ vi.mock("../../editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
TitleEditor: forwardRef(function MockTitleEditor(
{ defaultValue, placeholder, onBlur, onChange }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({

View File

@@ -195,6 +195,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const workspace = useWorkspaceStore((s) => s.workspace);
// Issue navigation — read from TQ list cache
@@ -264,7 +265,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();

View File

@@ -43,6 +43,7 @@ import {
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
@@ -63,7 +64,7 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinKeys } from "@multica/core/pins/queries";
import { pinListOptions } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
@@ -93,7 +94,6 @@ function DraftDot() {
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
const { push } = useNavigation();
useEffect(() => {
if (isDragging) wasDragged.current = true;
@@ -114,12 +114,13 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
>
<SidebarMenuButton
isActive={isActive}
onClick={() => {
render={<AppLink href={href} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
push(href);
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
@@ -129,17 +130,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
<FolderKanban className="size-4 shrink-0" />
)}
<span className="truncate">{label}</span>
<button
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</button>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</SidebarMenuAction>
</SidebarMenuItem>
);
}
@@ -158,6 +159,7 @@ interface AppSidebarProps {
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
@@ -174,10 +176,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
queryFn: () => api.listPins(),
enabled: !!wsId,
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();

View File

@@ -0,0 +1,223 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
},
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: (state: { workspace: { name: string } }) => unknown) => {
const state = { workspace: { name: "Test Workspace" } };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({
useIssueDraftStore: Object.assign(
(selector?: (state: typeof mockDraftStore) => unknown) =>
(selector ? selector(mockDraftStore) : mockDraftStore),
{ getState: () => mockDraftStore },
),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({
api: {},
}));
vi.mock("../editor", () => ({
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
uploadFile: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
);
}),
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
}));
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
StatusPicker: () => <div data-testid="status-picker" />,
PriorityPicker: () => <div data-testid="priority-picker" />,
AssigneePicker: () => <div data-testid="assignee-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
Upload file
</button>
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
custom: mockToastCustom,
dismiss: mockToastDismiss,
error: mockToastError,
},
}));
import { CreateIssueModal } from "./create-issue";
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "Ship create issue regression coverage",
description: undefined,
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-1"));
expect(screen.getByText("Issue created")).toBeInTheDocument();
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View issue" }));
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
});

View File

@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
@@ -193,6 +194,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
export function ProjectDetail({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
@@ -201,7 +203,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const { getActorName } = useActorName();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
const createPin = useCreatePin();
const deletePinMut = useDeletePin();

View File

@@ -228,7 +228,7 @@ export function SearchCommand() {
setOpen(false);
push(href);
},
[push],
[push, setOpen],
);
return (

View File

@@ -1,3 +1,4 @@
import "./e2e/env";
import { defineConfig } from "@playwright/test";
export default defineConfig({

View File

@@ -56,9 +56,21 @@ func allowedOrigins() []string {
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
// Initialize storage with S3 as primary, fallback to local
var store storage.Storage
s3 := storage.NewS3StorageFromEnv()
if s3 != nil {
store = s3
} else {
local := storage.NewLocalStorageFromEnv()
if local != nil {
store = local
}
}
cfSigner := auth.NewCloudFrontSignerFromEnv()
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
r := chi.NewRouter()
@@ -87,6 +99,14 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
realtime.HandleWebSocket(hub, mc, pr, w, r)
})
// Local file serving (when using local storage)
if local, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, file)
})
}
// Auth (public)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)

View File

@@ -134,12 +134,36 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, bus *events.
}
affectedAgents := make(map[string]pgtype.UUID)
processedIssues := make(map[string]bool)
for _, ft := range items {
// Look up workspace ID from the issue so the event reaches the right WS room.
workspaceID := ""
if issue, err := queries.GetIssue(ctx, ft.IssueID); err == nil {
workspaceID = util.UUIDToString(issue.WorkspaceID)
// If the issue is still in_progress and no other active tasks remain,
// reset it back to todo so the daemon can pick it up again.
issueKey := util.UUIDToString(ft.IssueID)
if issue.Status == "in_progress" && !processedIssues[issueKey] {
processedIssues[issueKey] = true
hasActive, checkErr := queries.HasActiveTaskForIssue(ctx, ft.IssueID)
if checkErr != nil {
slog.Warn("runtime sweeper: failed to check active tasks for issue",
"issue_id", issueKey,
"error", checkErr,
)
} else if !hasActive {
if _, updateErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: ft.IssueID,
Status: "todo",
}); updateErr != nil {
slog.Warn("runtime sweeper: failed to reset stuck issue to todo",
"issue_id", issueKey,
"error", updateErr,
)
}
}
}
}
bus.Publish(events.Event{

View File

@@ -300,6 +300,168 @@ func TestSweepDispatchedStaleTask(t *testing.T) {
}
}
// TestSweepResetsInProgressIssueToTodo verifies the core fix: when the sweeper
// force-fails a stale task whose issue is still in_progress (because the daemon
// crashed mid-run), the issue is reset back to todo so the daemon can re-queue it.
//
// Without this fix the issue stays in_progress permanently — the agent never runs
// to update the status because it was never dispatched.
func TestSweepResetsInProgressIssueToTodo(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
// Use the same agent/runtime as the other sweeper tests.
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create an issue already in in_progress (simulates a daemon crash mid-run).
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Stuck in_progress issue', 'in_progress', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create a stale running task for the issue (3 hours old — beyond any timeout).
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
// Fail the stale task (running timeout of 1 second — our task is 3 hours old).
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
// Confirm our task was swept.
found := false
for _, ft := range failedTasks {
if ft.ID.Bytes == parseUUIDBytes(taskID) {
found = true
break
}
}
if !found {
t.Fatalf("expected task %s to be in failed tasks, got %v", taskID, failedTasks)
}
// This is what we're testing: issue must be reset from in_progress → todo.
broadcastFailedTasks(ctx, queries, bus, failedTasks)
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "todo" {
t.Fatalf("expected issue status 'todo' after sweep, got '%s' — issue is stuck", issueStatus)
}
}
// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets
// issues that are truly stuck in in_progress — it must not clobber issues whose
// agents already moved them forward (e.g. to in_review) before the task timed out.
func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Issue already advanced to in_review by the agent before the task timed out.
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Already in_review issue', 'in_review', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
broadcastFailedTasks(ctx, queries, bus, failedTasks)
// Issue should remain in_review — the sweeper must not clobber agent progress.
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "in_review" {
t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus)
}
}
// parseUUIDBytes converts a UUID string to the 16-byte array used by pgtype.UUID.
func parseUUIDBytes(s string) [16]byte {
s = strings.ReplaceAll(s, "-", "")

View File

@@ -241,6 +241,7 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
}
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
slog.Error("failed to send verification code", "email", email, "error", err)
writeError(w, http.StatusInternalServerError, "failed to send verification code")
return
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
@@ -19,6 +18,7 @@ import (
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/storage"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type txStarter interface {
@@ -41,11 +41,11 @@ type Handler struct {
EmailService *service.EmailService
PingStore *PingStore
UpdateStore *UpdateStore
Storage *storage.S3Storage
Storage storage.Storage
CFSigner *auth.CloudFrontSigner
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
@@ -61,7 +61,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
EmailService: emailService,
PingStore: NewPingStore(),
UpdateStore: NewUpdateStore(),
Storage: s3,
Storage: store,
CFSigner: cfSigner,
}
}
@@ -77,14 +77,14 @@ func writeError(w http.ResponseWriter, status int, msg string) {
}
// Thin wrappers around util functions (preserve existing handler code unchanged).
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
// publish sends a domain event through the event bus.
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {

View File

@@ -135,16 +135,17 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
return
}
limit := int32(90)
if l := r.URL.Query().Get("days"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 365 {
limit = int32(parsed)
days := 90
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
since := pgtype.Date{Time: time.Now().AddDate(0, 0, -days), Valid: true}
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
RuntimeID: parseUUID(runtimeID),
Limit: limit,
Since: since,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage")

View File

@@ -1,7 +1,9 @@
package sanitize
import (
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
)
@@ -11,11 +13,6 @@ var httpURL = regexp.MustCompile(`^https?://`)
// policy is a shared bluemonday policy that allows safe Markdown HTML while
// stripping dangerous elements (script, iframe, object, embed, style, on*).
//
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
// for defense-in-depth — the primary sanitization happens in the frontend via
// rehype-sanitize which understands the Markdown AST.
var policy *bluemonday.Policy
func init() {
@@ -28,8 +25,47 @@ func init() {
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
}
// fencedCodeBlock matches ``` or ~~~ fenced code blocks (with optional language tag).
var fencedCodeBlock = regexp.MustCompile("(?m)^(```|~~~)[^\n]*\n[\\s\\S]*?\n(```|~~~)[ \t]*$")
// inlineCode matches backtick-delimited inline code spans.
// Ordered longest-delimiter-first so triple backticks match before doubles/singles.
var inlineCode = regexp.MustCompile("```[^`]+```|``[^`]+``|`[^`]+`")
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
//
// Code blocks and inline code spans are preserved verbatim so that bluemonday
// does not HTML-escape their contents (e.g. && → &amp;&amp;).
func HTML(input string) string {
return policy.Sanitize(input)
// 1. Extract fenced code blocks, replacing with unique placeholders.
var blocks []string
placeholder := func(i int) string { return fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) }
result := fencedCodeBlock.ReplaceAllStringFunc(input, func(m string) string {
idx := len(blocks)
blocks = append(blocks, m)
return placeholder(idx)
})
// 2. Extract inline code spans.
var inlines []string
inlinePH := func(i int) string { return fmt.Sprintf("\x00INLINE_%d\x00", i) }
result = inlineCode.ReplaceAllStringFunc(result, func(m string) string {
idx := len(inlines)
inlines = append(inlines, m)
return inlinePH(idx)
})
// 3. Sanitize the non-code portions.
result = policy.Sanitize(result)
// 4. Restore inline code spans, then fenced code blocks.
for i, code := range inlines {
result = strings.Replace(result, inlinePH(i), code, 1)
}
for i, block := range blocks {
result = strings.Replace(result, placeholder(i), block, 1)
}
return result
}

View File

@@ -80,6 +80,52 @@ func TestHTML(t *testing.T) {
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
},
// Code block preservation — entities must NOT be escaped inside code.
{
name: "fenced code block preserves ampersands",
input: "```\na && b\n```",
want: "```\na && b\n```",
},
{
name: "fenced code block preserves angle brackets",
input: "```html\n<div class=\"x\">hello</div>\n```",
want: "```html\n<div class=\"x\">hello</div>\n```",
},
{
name: "inline code preserves ampersands",
input: "run `a && b` in shell",
want: "run `a && b` in shell",
},
{
name: "inline code preserves angle brackets",
input: "use `x < y && y > z`",
want: "use `x < y && y > z`",
},
{
name: "double backtick inline code preserved",
input: "use ``a && b`` here",
want: "use ``a && b`` here",
},
{
name: "script in fenced code block preserved",
input: "```\n<script>alert(1)</script>\n```",
want: "```\n<script>alert(1)</script>\n```",
},
{
name: "script outside code block still stripped",
input: "hello <script>alert(1)</script> world",
want: "hello world",
},
{
name: "mixed code and non-code",
input: "text `a && b` more <script>x</script> end",
want: "text `a && b` more end",
},
{
name: "tilde fenced code block preserves content",
input: "~~~\na && b\n~~~",
want: "~~~\na && b\n~~~",
},
}
for _, tt := range tests {

View File

@@ -0,0 +1,114 @@
package storage
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
type LocalStorage struct {
uploadDir string
baseURL string
}
// NewLocalStorageFromEnv creates a LocalStorage from environment variables.
// Returns nil if upload directory cannot be created.
//
// Environment variables:
// - LOCAL_UPLOAD_DIR (default: "./data/uploads")
// - LOCAL_UPLOAD_BASE_URL (optional, e.g., "http://localhost:8080")
func NewLocalStorageFromEnv() *LocalStorage {
uploadDir := os.Getenv("LOCAL_UPLOAD_DIR")
if uploadDir == "" {
uploadDir = "./data/uploads"
}
if err := os.MkdirAll(uploadDir, 0755); err != nil {
slog.Error("failed to create upload directory", "dir", uploadDir, "error", err)
return nil
}
baseURL := strings.TrimSuffix(os.Getenv("LOCAL_UPLOAD_BASE_URL"), "/")
slog.Info("local storage initialized", "dir", uploadDir, "baseURL", baseURL)
return &LocalStorage{
uploadDir: uploadDir,
baseURL: baseURL,
}
}
func (s *LocalStorage) KeyFromURL(rawURL string) string {
if s.baseURL != "" && strings.HasPrefix(rawURL, s.baseURL) {
rawURL = strings.TrimPrefix(rawURL, s.baseURL)
}
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 i := strings.LastIndex(rawURL, "/"); i >= 0 {
return rawURL[i+1:]
}
return rawURL
}
func (s *LocalStorage) Delete(ctx context.Context, key string) {
if key == "" {
return
}
filePath := filepath.Join(s.uploadDir, key)
if err := os.Remove(filePath); err != nil {
if !os.IsNotExist(err) {
slog.Error("local storage Delete failed", "key", key, "error", err)
}
}
}
func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
for _, key := range keys {
s.Delete(ctx, key)
}
}
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.WriteFile(dest, data, 0644); err != nil {
return "", fmt.Errorf("local storage WriteFile: %w", err)
}
if s.baseURL != "" {
return fmt.Sprintf("%s/uploads/%s", s.baseURL, key), nil
}
return fmt.Sprintf("/uploads/%s", key), nil
}
func (s *LocalStorage) GetFilePath(key string) string {
return filepath.Join(s.uploadDir, key)
}
func (s *LocalStorage) ServeFile(w http.ResponseWriter, r *http.Request, filename string) {
filePath := filepath.Join(s.uploadDir, filename)
slog.Info("serving file", "filename", filename, "filepath", filePath)
// Use http.ServeFile which has built-in path traversal protection
// It sanitizes the path and prevents access outside the directory
http.ServeFile(w, r, filePath)
}
func (s *LocalStorage) UploadFromReader(ctx context.Context, key string, reader io.Reader, contentType string, filename string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("local storage ReadAll: %w", err)
}
return s.Upload(ctx, key, data, contentType, filename)
}

View File

@@ -0,0 +1,214 @@
package storage
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestLocalStorage_Upload(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
os.Unsetenv("LOCAL_UPLOAD_BASE_URL")
// No LOCAL_UPLOAD_BASE_URL set - should return relative path
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
expectedLink := "/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Upload_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
// When LOCAL_UPLOAD_BASE_URL is set, should return full URL
expectedLink := "http://localhost:8080/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Delete(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
_, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt")
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
filePath := filepath.Join(tmpDir, "delete-me.txt")
if _, err := os.ReadFile(filePath); err != nil {
t.Fatalf("file should exist: %v", err)
}
store.Delete(ctx, "delete-me.txt")
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file should be deleted")
}
}
func TestLocalStorage_KeyFromURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
// No baseURL set
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"local URL format", "/uploads/abc123.png", "abc123.png"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
{"just filename", "abc123.png", "abc123.png"},
{"full path", "/some/path/to/file.pdf", "file.pdf"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
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"},
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_DeleteKeys(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
keys := []string{"file1.txt", "file2.txt", "file3.txt"}
for _, key := range keys {
_, err := store.Upload(ctx, key, data, "text/plain", key)
if err != nil {
t.Fatalf("Upload %s failed: %v", key, err)
}
}
store.DeleteKeys(ctx, keys)
for _, key := range keys {
filePath := filepath.Join(tmpDir, key)
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file %s should be deleted", key)
}
}
}
func TestLocalStorage_KeyFromURL_Empty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if got := store.KeyFromURL(""); got != "" {
t.Errorf("KeyFromURL(\"\") = %q, want empty string", got)
}
}

View File

@@ -32,7 +32,7 @@ type S3Storage struct {
func NewS3StorageFromEnv() *S3Storage {
bucket := os.Getenv("S3_BUCKET")
if bucket == "" {
slog.Info("S3_BUCKET not set, file upload disabled")
slog.Info("S3_BUCKET not set, cloud upload disabled")
return nil
}
@@ -88,21 +88,6 @@ func (s *S3Storage) storageClass() types.StorageClass {
return types.StorageClassIntelligentTiering
}
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// KeyFromURL extracts the S3 object key from a CDN or bucket URL.
// e.g. "https://multica-static.copilothub.ai/abc123.png" → "abc123.png"
func (s *S3Storage) KeyFromURL(rawURL string) string {
@@ -150,16 +135,6 @@ func (s *S3Storage) DeleteKeys(ctx context.Context, keys []string) {
}
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
safe := sanitizeFilename(filename)
disposition := "attachment"

View File

@@ -0,0 +1,12 @@
package storage
import (
"context"
)
type Storage interface {
Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error)
Delete(ctx context.Context, key string)
DeleteKeys(ctx context.Context, keys []string)
KeyFromURL(rawURL string) string
}

View File

@@ -0,0 +1,30 @@
package storage
import (
"strings"
)
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}

View File

@@ -95,17 +95,17 @@ func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.U
const listRuntimeUsage = `-- name: ListRuntimeUsage :many
SELECT id, runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at FROM runtime_usage
WHERE runtime_id = $1
AND date >= $2
ORDER BY date DESC
LIMIT $2
`
type ListRuntimeUsageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Limit int32 `json:"limit"`
Since pgtype.Date `json:"since"`
}
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Limit)
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
if err != nil {
return nil, err
}

View File

@@ -12,8 +12,8 @@ DO UPDATE SET
-- name: ListRuntimeUsage :many
SELECT * FROM runtime_usage
WHERE runtime_id = $1
ORDER BY date DESC
LIMIT $2;
AND date >= $2
ORDER BY date DESC;
-- name: GetRuntimeUsageSummary :many
SELECT provider, model,