merge: resolve conflicts with main (open_only pagination)

- Resolve issues/store.ts: keep client-only store, port pagination
  strategy (open_only + closed page) to core/issues/queries.ts
- Resolve issues-page.tsx, batch-action-toolbar.tsx: keep TQ mutations
- Auto-merge agents/page.tsx trigger null fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-07 18:08:35 +08:00
parent 6032b5dfcb
commit 348133b63d
24 changed files with 1489 additions and 51 deletions

View file

@ -29,6 +29,7 @@ RESEND_FROM_EMAIL=noreply@multica.ai
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=

View file

@ -282,6 +282,22 @@ function LoginPageContent() {
);
}
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
const handleGoogleLogin = () => {
if (!googleClientId) return;
const redirectUri = `${window.location.origin}/auth/callback`;
const params = new URLSearchParams({
client_id: googleClientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
prompt: "select_account",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
};
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
@ -307,7 +323,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
form="login-form"
@ -317,6 +333,46 @@ function LoginPageContent() {
>
{submitting ? "Sending code..." : "Continue"}
</Button>
{googleClientId && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
size="lg"
onClick={handleGoogleLogin}
disabled={submitting}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
</>
)}
</CardFooter>
</Card>
</div>

View file

@ -923,7 +923,13 @@ function TriggersTab({
</div>
<div className="space-y-2">
{triggers.map((trigger) => (
{triggers.map((trigger) => {
const scheduledConfig = (trigger.config ?? {}) as {
cron?: string;
timezone?: string;
};
return (
<div
key={trigger.id}
className="rounded-lg border px-4 py-3"
@ -951,7 +957,7 @@ function TriggersTab({
? "Runs when an issue is assigned to this agent"
: trigger.type === "on_comment"
? "Runs when a member comments on the agent's issue"
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
: `Cron: ${scheduledConfig.cron ?? "Not set"}`}
</div>
</div>
<div className="flex items-center gap-2">
@ -986,10 +992,10 @@ function TriggersTab({
</Label>
<Input
type="text"
value={(trigger.config as { cron?: string }).cron ?? ""}
value={scheduledConfig.cron ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...trigger.config,
...scheduledConfig,
cron: e.target.value,
})
}
@ -1003,10 +1009,10 @@ function TriggersTab({
</Label>
<Input
type="text"
value={(trigger.config as { timezone?: string }).timezone ?? ""}
value={scheduledConfig.timezone ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...trigger.config,
...scheduledConfig,
timezone: e.target.value,
})
}
@ -1017,7 +1023,8 @@ function TriggersTab({
</div>
)}
</div>
))}
);
})}
</div>
<div className="flex gap-2">

View file

@ -0,0 +1,90 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Loader2 } from "lucide-react";
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
useEffect(() => {
const code = searchParams.get("code");
if (!code) {
setError("Missing authorization code");
return;
}
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "Access denied" : errorParam);
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Login Failed</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Signing in...</CardTitle>
<CardDescription>Please wait while we complete your login</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={null}>
<CallbackContent />
</Suspense>
);
}

View file

@ -12,15 +12,28 @@ export const issueKeys = {
["issues", "subscribers", issueId] as const,
};
const CLOSED_PAGE_SIZE = 50;
/**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) NOT setQueryData<Issue[]>.
*
* Fetches all open issues + first page of closed issues (matching main's pagination strategy).
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: () => api.listIssues({ limit: 200 }),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true }),
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
};
},
select: (data) => data.issues,
});
}

View file

@ -12,6 +12,7 @@ interface AuthState {
initialize: () => Promise<void>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
@ -53,6 +54,15 @@ export const useAuthStore = create<AuthState>((set) => ({
return user;
},
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
set({ user });
return user;
},
logout: () => {
localStorage.removeItem("multica_token");
api.setToken(null);

View file

@ -272,6 +272,35 @@ export const en: LandingDict = {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
entries: [
{
version: "0.1.8",
date: "2026-04-07",
title: "OAuth, OpenClaw & Issue Loading",
changes: [
"Google OAuth login",
"OpenClaw runtime support for running agents on OpenClaw infrastructure",
"Redesigned agent live card — always sticky with manual expand/collapse toggle",
"Load all open issues without pagination limit; closed issues paginate on scroll",
"JWT and CloudFront cookie expiration extended from 72 hours to 30 days",
"Remember last selected workspace after re-login",
"Daemon ensures multica CLI is on PATH in agent task environment",
"PR template and CLI install guide for agent-driven setup",
],
},
{
version: "0.1.7",
date: "2026-04-05",
title: "Comment Pagination & CLI Polish",
changes: [
"Comment list pagination in both the API and CLI",
"Inbox archive now dismisses all items for the same issue at once",
"CLI help output overhauled to match gh CLI style with examples",
"Attachments use UUIDv7 as S3 key and auto-link on issue/comment creation",
"@mention assigned agents on done or cancelled issues",
"Reply @mention inheritance skips when the reply only mentions members",
"Worktree setup preserves existing .env.worktree variables",
],
},
{
version: "0.1.6",
date: "2026-04-03",

View file

@ -272,6 +272,35 @@ export const zh: LandingDict = {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
entries: [
{
version: "0.1.8",
date: "2026-04-07",
title: "OAuth、OpenClaw 与 Issue 加载优化",
changes: [
"支持 Google OAuth 登录",
"新增 OpenClaw 运行时,支持在 OpenClaw 基础设施上运行 Agent",
"Agent 实时卡片重新设计——始终吸顶,支持手动展开/收起",
"打开的 Issue 不再分页限制全量加载,已关闭的 Issue 滚动分页",
"JWT 和 CloudFront Cookie 有效期从 72 小时延长至 30 天",
"重新登录后记住上次选择的工作区",
"守护进程确保 Agent 任务环境中 multica CLI 在 PATH 上",
"新增 PR 模板和面向 Agent 的 CLI 安装指南",
],
},
{
version: "0.1.7",
date: "2026-04-05",
title: "评论分页与 CLI 优化",
changes: [
"评论列表支持分页API 和 CLI 均已适配",
"收件箱归档操作现在一次性归档同一 Issue 的所有通知",
"CLI 帮助输出重新设计,匹配 gh CLI 风格并增加示例",
"附件使用 UUIDv7 作为 S3 key创建 Issue/评论时自动关联附件",
"支持在已完成或已取消的 Issue 上 @提及已分配的 Agent",
"回复仅 @提及成员时跳过父级提及继承逻辑",
"Worktree 环境配置保留已有的 .env.worktree 变量",
],
},
{
version: "0.1.6",
date: "2026-04-03",

View file

@ -144,6 +144,13 @@ export class ApiClient {
});
}
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
return this.fetch("/auth/google", {
method: "POST",
body: JSON.stringify({ code, redirect_uri: redirectUri }),
});
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@ -165,6 +172,7 @@ export class ApiClient {
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.open_only) search.set("open_only", "true");
return this.fetch(`/api/issues?${search}`);
}

View file

@ -36,7 +36,7 @@ export interface AgentTrigger {
id: string;
type: AgentTriggerType;
enabled: boolean;
config: Record<string, unknown>;
config: Record<string, unknown> | null;
}
export interface AgentTask {

View file

@ -32,6 +32,7 @@ export interface ListIssuesParams {
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
open_only?: boolean;
}
export interface ListIssuesResponse {

View file

@ -82,6 +82,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// Auth (public)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
r.Post("/auth/google", h.GoogleLogin)
// Daemon API routes (all require a valid token)
r.Route("/api/daemon", func(r chi.Router) {

View file

@ -30,7 +30,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
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry, "openclaw" -> entry
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)
@ -92,8 +92,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")),
}
}
openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw")
if _, err := exec.LookPath(openclawPath); err == nil {
agents["openclaw"] = AgentEntry{
Path: openclawPath,
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")),
}
}
if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, or opencode and ensure it is on PATH")
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, or openclaw and ensure it is on PATH")
}
// Host info

View file

@ -921,6 +921,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
"MULTICA_AGENT_ID": task.AgentID,
"MULTICA_TASK_ID": task.ID,
}
// Ensure the multica CLI is on PATH inside the agent's environment.
// Some runtimes (e.g. Codex) run in an isolated sandbox that may not
// inherit the daemon's PATH. Prepend the directory of the running
// multica binary so that `multica` commands in the agent always resolve.
if selfBin, err := os.Executable(); err == nil {
binDir := filepath.Dir(selfBin)
agentEnv["PATH"] = binDir + string(os.PathListSeparator) + os.Getenv("PATH")
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
// without polluting the system ~/.codex/skills/.
if env.CodexHome != "" {

View file

@ -13,13 +13,14 @@ import (
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
case "codex", "opencode":
case "codex", "opencode", "openclaw":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
default:
// Unknown provider — skip config injection, prompt-only mode.
@ -49,13 +50,18 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n")
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
b.WriteString("- `multica workspace members [workspace-id] --output json` — List workspace members (user IDs, names, roles)\n")
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
b.WriteString("- `multica repo checkout <url>` — Check out a repository into the working directory (creates a git worktree with a dedicated branch)\n")
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
b.WriteString("- `multica issue run-messages <task-id> [--since <seq>] --output json` — List messages for a specific execution run (supports incremental fetch)\n")
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n\n")
b.WriteString("### Write\n")
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("- `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")
@ -99,13 +105,16 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString(" a. Run `multica repo checkout <url>` to check out the appropriate repository\n")
b.WriteString(" b. `cd` into the checked-out directory\n")
b.WriteString(" c. Implement the changes and commit\n")
b.WriteString(" d. Push the branch to the remote\n")
b.WriteString(" e. Create a pull request (decide the target branch based on the repo's conventions)\n")
fmt.Fprintf(&b, " f. Post the PR link as a comment: `multica issue comment add %s --content \"PR: <url>\"`\n", ctx.IssueID)
} else {
b.WriteString(" a. Create a new branch\n")
b.WriteString(" b. Implement the changes and commit\n")
b.WriteString(" c. Push the branch to the remote\n")
b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n")
fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: <url>\"`\n", ctx.IssueID)
}
b.WriteString(" c. Push the branch to the remote\n")
b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n")
fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: <url>\"`\n", ctx.IssueID)
b.WriteString("5. If the task does not require code (e.g. research, documentation), post your findings as a comment\n")
fmt.Fprintf(&b, "6. Run `multica issue status %s in_review`\n", ctx.IssueID)
fmt.Fprintf(&b, "7. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID)
@ -117,8 +126,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
case "claude":
// Claude discovers skills natively from .claude/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "codex", "opencode":
// Codex and OpenCode discover skills natively from their respective paths — just list names.
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")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")

View file

@ -7,8 +7,10 @@ import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
@ -175,7 +177,7 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
"sub": uuidToString(user.ID),
"email": user.Email,
"name": user.Name,
"exp": time.Now().Add(72 * time.Hour).Unix(),
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString(auth.JWTSecret())
@ -302,7 +304,7 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
// Set CloudFront signed cookies for CDN access.
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) {
http.SetCookie(w, cookie)
}
}
@ -334,6 +336,162 @@ type UpdateMeRequest struct {
AvatarURL *string `json:"avatar_url"`
}
type GoogleLoginRequest struct {
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
}
type googleTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
type googleUserInfo struct {
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
var req GoogleLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusServiceUnavailable, "Google login is not configured")
return
}
redirectURI := req.RedirectURI
if redirectURI == "" {
redirectURI = os.Getenv("GOOGLE_REDIRECT_URI")
}
// Exchange authorization code for tokens.
tokenResp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{
"code": {req.Code},
"client_id": {clientID},
"client_secret": {clientSecret},
"redirect_uri": {redirectURI},
"grant_type": {"authorization_code"},
})
if err != nil {
slog.Error("google oauth token exchange failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to exchange code with Google")
return
}
defer tokenResp.Body.Close()
tokenBody, err := io.ReadAll(tokenResp.Body)
if err != nil {
writeError(w, http.StatusBadGateway, "failed to read Google token response")
return
}
if tokenResp.StatusCode != http.StatusOK {
slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody))
writeError(w, http.StatusBadRequest, "failed to exchange code with Google")
return
}
var gToken googleTokenResponse
if err := json.Unmarshal(tokenBody, &gToken); err != nil {
writeError(w, http.StatusBadGateway, "failed to parse Google token response")
return
}
// Fetch user info from Google.
userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
userInfoResp, err := http.DefaultClient.Do(userInfoReq)
if err != nil {
slog.Error("google userinfo fetch failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to fetch user info from Google")
return
}
defer userInfoResp.Body.Close()
var gUser googleUserInfo
if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil {
writeError(w, http.StatusBadGateway, "failed to parse Google user info")
return
}
if gUser.Email == "" {
writeError(w, http.StatusBadRequest, "Google account has no email")
return
}
email := strings.ToLower(strings.TrimSpace(gUser.Email))
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
// Update name and avatar from Google profile if the user was just created
// (default name is email prefix) or has no avatar yet.
needsUpdate := false
newName := user.Name
newAvatar := user.AvatarUrl
if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] {
newName = gUser.Name
needsUpdate = true
}
if gUser.Picture != "" && !user.AvatarUrl.Valid {
newAvatar = pgtype.Text{String: gUser.Picture, Valid: true}
needsUpdate = true
}
if needsUpdate {
updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
ID: user.ID,
Name: newName,
AvatarUrl: newAvatar,
})
if err == nil {
user = updated
}
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
http.SetCookie(w, cookie)
}
}
slog.Info("user logged in via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
writeJSON(w, http.StatusOK, LoginResponse{
Token: tokenString,
User: userToResponse(user),
})
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {

View file

@ -357,9 +357,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
// re-triggered by subsequent replies in the same thread — unless the reply
// explicitly @mentions only non-agent entities (members, issues), which
// signals the user is talking to other people and not the agent.
// Skips self-mentions, agents that are already the issue's assignee (handled
// by on_comment), agents with on_mention trigger disabled, and private agents
// mentioned by non-owner members (only the agent owner or workspace
// Skips self-mentions, agents with on_mention trigger disabled, and private
// agents mentioned by non-owner members (only the agent owner or workspace
// admin/owner can mention a private agent).
// Note: no status gate here — @mention is an explicit action and should work
// even on done/cancelled issues (the agent can reopen the issue if needed).
@ -404,17 +403,6 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
continue
}
agentUUID := parseUUID(m.ID)
// Prevent duplicate: skip if this agent is the issue's assignee
// (already handled by the on_comment trigger above) — but only
// when the issue is in a non-terminal status where on_comment
// will actually fire. For done/cancelled issues on_comment is
// suppressed, so an explicit @mention must still go through.
isAssignee := issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID
isTerminal := issue.Status == "done" || issue.Status == "cancelled"
if isAssignee && !isTerminal {
continue
}
// Load the agent to check visibility, archive status, and trigger config.
agent, err := h.Queries.GetAgent(ctx, agentUUID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {

View file

@ -83,6 +83,42 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := resolveWorkspaceID(r)
wsUUID := parseUUID(workspaceID)
// Parse optional filter params
var priorityFilter pgtype.Text
if p := r.URL.Query().Get("priority"); p != "" {
priorityFilter = pgtype.Text{String: p, Valid: true}
}
var assigneeFilter pgtype.UUID
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
// open_only=true returns all non-done/cancelled issues (no limit).
if r.URL.Query().Get("open_only") == "true" {
issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
WorkspaceID: wsUUID,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
prefix := h.getIssuePrefix(ctx, wsUUID)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueToResponse(issue, prefix)
}
writeJSON(w, http.StatusOK, map[string]any{
"issues": resp,
"total": len(resp),
})
return
}
limit := 100
offset := 0
@ -97,22 +133,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
}
// Parse optional filter params
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
var priorityFilter pgtype.Text
if p := r.URL.Query().Get("priority"); p != "" {
priorityFilter = pgtype.Text{String: p, Valid: true}
}
var assigneeFilter pgtype.UUID
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
WorkspaceID: wsUUID,
Limit: int32(limit),
Offset: int32(offset),
Status: statusFilter,
@ -124,7 +151,18 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
return
}
prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID))
// Get the true total count for pagination awareness.
total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{
WorkspaceID: wsUUID,
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
})
if err != nil {
total = int64(len(issues))
}
prefix := h.getIssuePrefix(ctx, wsUUID)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueToResponse(issue, prefix)
@ -132,7 +170,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"issues": resp,
"total": len(resp),
"total": total,
})
}

View file

@ -18,7 +18,7 @@ func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := r.Cookie("CloudFront-Policy"); err != nil {
for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) {
for _, cookie := range signer.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) {
http.SetCookie(w, cookie)
}
}

View file

@ -1,5 +1,5 @@
// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli AgentBackend
// coding agents (Claude Code, Codex, OpenCode, OpenClaw). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
package agent
@ -73,13 +73,13 @@ type Result struct {
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codex, or opencode)
ExecutablePath string // path to CLI binary (claude, codex, opencode, or openclaw)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codex", "opencode".
// Supported types: "claude", "codex", "opencode", "openclaw".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@ -92,8 +92,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &codexBackend{cfg: cfg}, nil
case "opencode":
return &opencodeBackend{cfg: cfg}, nil
case "openclaw":
return &openclawBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode)", agentType)
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw)", agentType)
}
}

View file

@ -0,0 +1,313 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
"time"
)
// openclawBackend implements Backend by spawning `openclaw agent -p <prompt>
// --output-format stream-json --yes` and reading streaming NDJSON events from
// stdout — similar to the opencode backend.
type openclawBackend struct {
cfg Config
}
func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "openclaw"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("openclaw 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 := []string{"agent", "--output-format", "stream-json", "--yes"}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.SystemPrompt != "" {
args = append(args, "--system-prompt", opts.SystemPrompt)
}
if opts.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
if opts.ResumeSessionID != "" {
args = append(args, "--session", opts.ResumeSessionID)
}
args = append(args, "-p", prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
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("openclaw stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start openclaw: %w", err)
}
b.cfg.Logger.Info("openclaw started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
scanResult := b.processEvents(stdout, msgCh)
// Wait for process exit.
exitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
scanResult.status = "timeout"
scanResult.errMsg = fmt.Sprintf("openclaw timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
scanResult.status = "aborted"
scanResult.errMsg = "execution cancelled"
} else if exitErr != nil && scanResult.status == "completed" {
scanResult.status = "failed"
scanResult.errMsg = fmt.Sprintf("openclaw exited with error: %v", exitErr)
}
b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: scanResult.status,
Output: scanResult.output,
Error: scanResult.errMsg,
DurationMs: duration.Milliseconds(),
SessionID: scanResult.sessionID,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// ── Event handlers ──
// openclawEventResult holds accumulated state from processing the event stream.
type openclawEventResult struct {
status string
errMsg string
output string
sessionID string
}
// processEvents reads NDJSON lines from r, dispatches events to ch, and returns
// the accumulated result.
func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclawEventResult {
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var event openclawEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
if event.SessionID != "" {
sessionID = event.SessionID
}
switch event.Type {
case "text":
b.handleOCTextEvent(event, ch, &output)
case "thinking":
b.handleOCThinkingEvent(event, ch)
case "tool_call":
b.handleOCToolCallEvent(event, ch)
case "error":
// NOTE: error events unconditionally set finalStatus to "failed" and
// it stays sticky — subsequent text or result events won't revert it.
// This is intentional: once an error fires, the session is considered
// failed regardless of later events.
b.handleOCErrorEvent(event, ch, &finalStatus, &finalError)
case "step_start":
trySend(ch, Message{Type: MessageStatus, Status: "running"})
case "step_end":
// Captures final session ID from step_end if present.
case "result":
// The result event only updates status on explicit failure. A
// "completed" result is a no-op because finalStatus defaults to
// "completed". Any unrecognized status (e.g. "partial") is also
// treated as success — update this if OpenClaw adds new statuses.
if event.Data != nil {
if s, ok := event.Data["status"].(string); ok && s != "" {
if s == "error" || s == "failed" {
finalStatus = "failed"
if msg, ok := event.Data["error"].(string); ok {
finalError = msg
}
}
}
}
}
}
// Check for scanner errors (e.g. broken pipe, read errors).
if scanErr := scanner.Err(); scanErr != nil {
b.cfg.Logger.Warn("openclaw stdout scanner error", "error", scanErr)
if finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
}
}
return openclawEventResult{
status: finalStatus,
errMsg: finalError,
output: output.String(),
sessionID: sessionID,
}
}
func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Message, output *strings.Builder) {
text := openclawExtractText(event.Data)
if text != "" {
output.WriteString(text)
trySend(ch, Message{Type: MessageText, Content: text})
}
}
func (b *openclawBackend) handleOCThinkingEvent(event openclawEvent, ch chan<- Message) {
text := openclawExtractText(event.Data)
if text != "" {
trySend(ch, Message{Type: MessageThinking, Content: text})
}
}
// handleOCToolCallEvent processes "tool_call" events from OpenClaw. A single
// tool_call event may contain both the call and result when the tool has
// completed (status == "completed").
func (b *openclawBackend) handleOCToolCallEvent(event openclawEvent, ch chan<- Message) {
if event.Data == nil {
return
}
name, _ := event.Data["name"].(string)
callID, _ := event.Data["callId"].(string)
// Extract input.
var input map[string]any
if raw, ok := event.Data["input"]; ok {
if m, ok := raw.(map[string]any); ok {
input = m
}
}
// Emit the tool-use message.
trySend(ch, Message{
Type: MessageToolUse,
Tool: name,
CallID: callID,
Input: input,
})
// If the tool has completed, also emit a tool-result message.
status, _ := event.Data["status"].(string)
if status == "completed" {
outputStr := extractToolOutput(event.Data["output"])
trySend(ch, Message{
Type: MessageToolResult,
Tool: name,
CallID: callID,
Output: outputStr,
})
}
}
func (b *openclawBackend) handleOCErrorEvent(event openclawEvent, ch chan<- Message, finalStatus, finalError *string) {
errMsg := ""
if event.Data != nil {
if msg, ok := event.Data["message"].(string); ok {
errMsg = msg
}
if errMsg == "" {
if code, ok := event.Data["code"].(string); ok {
errMsg = code
}
}
}
if errMsg == "" {
errMsg = "unknown openclaw error"
}
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
*finalStatus = "failed"
*finalError = errMsg
}
// openclawExtractText extracts text content from an openclaw event data map.
// Supports both flat {"text": "..."} and nested {"content": {"text": "..."}} layouts.
func openclawExtractText(data map[string]any) string {
if data == nil {
return ""
}
// Try "text" field directly.
if text, ok := data["text"].(string); ok {
return text
}
// Try nested "content.text".
if content, ok := data["content"].(map[string]any); ok {
if text, ok := content["text"].(string); ok {
return text
}
}
return ""
}
// ── JSON types for `openclaw agent --output-format stream-json` stdout events ──
// openclawEvent represents a single NDJSON line from OpenClaw's stream-json output.
//
// Event types:
//
// "step_start" — agent step begins
// "text" — text output from agent
// "thinking" — model reasoning/thinking
// "tool_call" — tool invocation with call and result
// "error" — error from openclaw
// "step_end" — agent step completes
// "result" — final result with status
type openclawEvent struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
Data map[string]any `json:"data,omitempty"`
}

View file

@ -0,0 +1,574 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestNewReturnsOpenclawBackend(t *testing.T) {
t.Parallel()
b, err := New("openclaw", Config{ExecutablePath: "/nonexistent/openclaw"})
if err != nil {
t.Fatalf("New(openclaw) error: %v", err)
}
if _, ok := b.(*openclawBackend); !ok {
t.Fatalf("expected *openclawBackend, got %T", b)
}
}
// ── Text event tests ──
func TestOpenclawHandleTextEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{
Type: "text",
SessionID: "ses_abc",
Data: map[string]any{"text": "Hello from openclaw"},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "Hello from openclaw" {
t.Errorf("output: got %q, want %q", output.String(), "Hello from openclaw")
}
msg := <-ch
if msg.Type != MessageText {
t.Errorf("type: got %v, want MessageText", msg.Type)
}
if msg.Content != "Hello from openclaw" {
t.Errorf("content: got %q, want %q", msg.Content, "Hello from openclaw")
}
}
func TestOpenclawHandleTextEventEmpty(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{
Type: "text",
Data: map[string]any{"text": ""},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
func TestOpenclawHandleTextEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{Type: "text"}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
// ── Thinking event tests ──
func TestOpenclawHandleThinkingEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{"text": "Let me think about this..."},
}
b.handleOCThinkingEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
}
if msg.Content != "Let me think about this..." {
t.Errorf("content: got %q", msg.Content)
}
}
// ── Tool call event tests ──
func TestOpenclawHandleToolCallCompleted(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "bash",
"callId": "call_123",
"input": map[string]any{"command": "pwd"},
"status": "completed",
"output": "/tmp/project\n",
},
}
b.handleOCToolCallEvent(event, ch)
// Should emit both tool-use and tool-result.
if len(ch) != 2 {
t.Fatalf("expected 2 messages, got %d", len(ch))
}
// First: tool-use
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
if msg.Tool != "bash" {
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
}
if msg.CallID != "call_123" {
t.Errorf("callID: got %q, want %q", msg.CallID, "call_123")
}
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
t.Errorf("input.command: got %v", msg.Input["command"])
}
// Second: tool-result
msg = <-ch
if msg.Type != MessageToolResult {
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
}
if msg.Output != "/tmp/project\n" {
t.Errorf("output: got %q", msg.Output)
}
}
func TestOpenclawHandleToolCallPending(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "read",
"callId": "call_456",
"input": map[string]any{"filePath": "/tmp/test.go"},
"status": "pending",
},
}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
}
func TestOpenclawHandleToolCallNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{Type: "tool_call"}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 0 {
t.Errorf("expected no messages for nil data, got %d", len(ch))
}
}
// ── Error event tests ──
func TestOpenclawHandleErrorEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
SessionID: "ses_abc",
Data: map[string]any{"message": "Model not found: bad/model"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if status != "failed" {
t.Errorf("status: got %q, want %q", status, "failed")
}
if errMsg != "Model not found: bad/model" {
t.Errorf("error: got %q", errMsg)
}
msg := <-ch
if msg.Type != MessageError {
t.Errorf("type: got %v, want MessageError", msg.Type)
}
}
func TestOpenclawHandleErrorEventCodeOnly(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
Data: map[string]any{"code": "RateLimitError"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "RateLimitError" {
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
}
}
func TestOpenclawHandleErrorEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{Type: "error"}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "unknown openclaw error" {
t.Errorf("error: got %q, want %q", errMsg, "unknown openclaw error")
}
}
// ── Integration-level tests: processEvents ──
func TestOpenclawProcessEventsHappyPath(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Simulate a successful run: step_start → text → tool_call → text → step_end
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_happy"}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":"Analyzing..."}}`,
`{"type":"tool_call","sessionId":"ses_happy","data":{"name":"bash","callId":"call_1","input":{"command":"ls"},"status":"completed","output":"file.go\n"}}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":" Done."}}`,
`{"type":"step_end","sessionId":"ses_happy"}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.sessionID != "ses_happy" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
}
if result.output != "Analyzing... Done." {
t.Errorf("output: got %q, want %q", result.output, "Analyzing... Done.")
}
if result.errMsg != "" {
t.Errorf("errMsg: got %q, want empty", result.errMsg)
}
// Drain and verify messages.
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
// Expected: status(running), text, tool-use, tool-result, text = 5 messages
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
}
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing..." {
t.Errorf("msg[1]: got %+v", msgs[1])
}
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
}
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file.go\n" {
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
}
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
t.Errorf("msg[4]: got %+v", msgs[4])
}
}
func TestOpenclawProcessEventsErrorCausesFailedStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_err"}`,
`{"type":"error","sessionId":"ses_err","data":{"message":"Model not found: bad/model"}}`,
`{"type":"step_end","sessionId":"ses_err"}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "Model not found: bad/model" {
t.Errorf("errMsg: got %q", result.errMsg)
}
if result.sessionID != "ses_err" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
}
close(ch)
var errorMsgs int
for m := range ch {
if m.Type == MessageError {
errorMsgs++
}
}
if errorMsgs != 1 {
t.Errorf("expected 1 error message, got %d", errorMsgs)
}
}
func TestOpenclawProcessEventsSessionIDExtracted(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_first"}`,
`{"type":"text","sessionId":"ses_updated","data":{"text":"hi"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.sessionID != "ses_updated" {
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
}
close(ch)
}
func TestOpenclawProcessEventsScannerError(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := b.processEvents(&ioErrReader{
data: `{"type":"text","sessionId":"ses_scan","data":{"text":"before error"}}` + "\n",
}, ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if !strings.Contains(result.errMsg, "stdout read error") {
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
}
if result.output != "before error" {
t.Errorf("output: got %q, want %q", result.output, "before error")
}
close(ch)
}
func TestOpenclawProcessEventsEmptyLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
"",
" ",
"not json at all",
`{"type":"text","sessionId":"ses_ok","data":{"text":"valid"}}`,
"",
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "valid" {
t.Errorf("output: got %q, want %q", result.output, "valid")
}
if result.sessionID != "ses_ok" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 1 || msgs[0].Type != MessageText {
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
}
}
func TestOpenclawProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"error","sessionId":"ses_x","data":{"message":"RateLimitError"}}`,
`{"type":"text","sessionId":"ses_x","data":{"text":"recovered?"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
}
if result.errMsg != "RateLimitError" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
}
close(ch)
}
func TestOpenclawProcessEventsResultEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"text","sessionId":"ses_r","data":{"text":"Done"}}`,
`{"type":"result","sessionId":"ses_r","data":{"status":"completed"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "Done" {
t.Errorf("output: got %q, want %q", result.output, "Done")
}
close(ch)
}
func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"result","sessionId":"ses_rf","data":{"status":"error","error":"out of tokens"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "out of tokens" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "out of tokens")
}
close(ch)
}
// ── openclawExtractText tests ──
func TestExtractEventTextDirect(t *testing.T) {
t.Parallel()
data := map[string]any{"text": "hello"}
if got := openclawExtractText(data); got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestExtractEventTextNested(t *testing.T) {
t.Parallel()
data := map[string]any{
"content": map[string]any{"text": "nested hello"},
}
if got := openclawExtractText(data); got != "nested hello" {
t.Errorf("got %q, want %q", got, "nested hello")
}
}
func TestExtractEventTextNil(t *testing.T) {
t.Parallel()
if got := openclawExtractText(nil); got != "" {
t.Errorf("got %q, want empty", got)
}
}
// ── Thinking event with nested content ──
func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{
"content": map[string]any{"text": "Nested thinking"},
},
}
b.handleOCThinkingEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
}
if msg.Content != "Nested thinking" {
t.Errorf("content: got %q, want %q", msg.Content, "Nested thinking")
}
}

View file

@ -11,6 +11,33 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countIssues = `-- name: CountIssues :one
SELECT count(*) FROM issue
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
AND ($3::text IS NULL OR priority = $3)
AND ($4::uuid IS NULL OR assignee_id = $4)
`
type CountIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
}
func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssues,
arg.WorkspaceID,
arg.Status,
arg.Priority,
arg.AssigneeID,
)
var count int64
err := row.Scan(&count)
return count, err
}
const createIssue = `-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
@ -254,6 +281,60 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
return items, nil
}
const listOpenIssues = `-- name: ListOpenIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
AND ($2::text IS NULL OR priority = $2)
AND ($3::uuid IS NULL OR assignee_id = $3)
ORDER BY position ASC, created_at DESC
`
type ListOpenIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
}
func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]Issue, error) {
rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, arg.Priority, arg.AssigneeID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Issue{}
for rows.Next() {
var i Issue
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateIssue = `-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE($2, title),

View file

@ -51,3 +51,18 @@ RETURNING *;
-- name: DeleteIssue :exec
DELETE FROM issue WHERE id = $1;
-- name: ListOpenIssues :many
SELECT * FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
ORDER BY position ASC, created_at DESC;
-- name: CountIssues :one
SELECT count(*) FROM issue
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'));