diff --git a/AGENTS.md b/AGENTS.md index 5b2b209..e4494ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,24 +7,26 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel / Restore,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 -- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel / Restore,审批裁决,用户侧记忆查看/删除,Git(直接调 `pkg/git`),以及看板 `/works` `/board`(调 `internal/board`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 +- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。本回合记忆装 user + 当前 Work(`ScopeWork`);`workspace` 热层保留但不进本回合。有 placement 时 native Loop 追加 Work Packet 为独立 system。 +- 产品工作流放在 `server/internal/board`:Work / Checkout / Info / Placement / Board 聚合 / Inbox。不写记忆、不 spawn CLI、不建 worktree。不进 `pkg/agent`。 - 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。六个口的信封放在 `pkg/agent/seam`。 - 插件 SDK 与 proto 放在 `server/pkg/plugin`;宿主放在 `server/internal/pluginhost`。两者都不进 `pkg/agent`,也不知道主循环内部状态机。可装载的插件放在仓根 `plugin/`(子目录名即插件名)。`plugin/example` 是作者拷贝模板:不订阅、不改正文、不换向、不登记方法。不进 `pkg/plugin`。插件共享参数用 `PluginContext`,不进模型、不复用 Hidden。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 +- Issue/PR 只读快照放在 `server/pkg/github`:无状态,走本机 `gh`;不关 issue、不合 PR、不落 token。不进 `pkg/agent`。 - Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。 - Claude Code 对接放在 `server/pkg/claude`:无状态,不落库;会话 / 实录 / 模型 / 权限档从本机 Claude 读。Handler 直接调用。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 - 运行时产生的文件(SQLite 等)放在仓根 `data/`,不要写进 `server/`。该目录 gitignore。 -- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,不扩 `AgentClient`。 +- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`、`board/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,看板用独立 `BoardClient`,不扩 `AgentClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 -- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,由 `ChatPage` 按模式组合,不单独做 Codex / Claude 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。不解析 SSE。 +- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`、`board/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,看板用 `views/board` 的 `BoardProvider`,由 `ChatPage` 组会话 / 看板两态,不单独做 Codex / Claude / 看板页。右侧窗口栏只留 Plan / 文件 / Git。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient` / `BoardClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`,看板走 `/board`。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 @@ -40,9 +42,9 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - Agent 契约不得依赖 React、UI 包或路由框架。 - 流式事件是通知,不是真实数据源;重连时应按 `event_seq` 回放。 - 不要预先创建 Issue、Task、Review、Workspace 或其他具体业务域目录。 -- 不要把产品工作流放入 `server/pkg`。Git、Codex 与 Claude Code 是本机 CLI 域:不落库,Handler 直接调 `pkg/git` / `pkg/codex` / `pkg/claude`。 +- 不要把产品工作流放入 `server/pkg`。Git、Codex 与 Claude Code 是本机 CLI 域:不落库,Handler 直接调 `pkg/git` / `pkg/codex` / `pkg/claude`。看板工作流在 `internal/board`;`pkg/github` 只做 gh 快照。 - 前端三层不得反依赖:`core` 不依赖 React / Next / DOM / `process.env`;`ui` 不依赖 `core`;`views` 不 import `next/*`;`apps/web` 只做路由与平台装配。 -- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex` / `claude`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 +- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex` / `claude` / `board`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 ## 注释规则 diff --git a/apps/web/app/(chat)/board/page.tsx b/apps/web/app/(chat)/board/page.tsx new file mode 100644 index 0000000..8d29a08 --- /dev/null +++ b/apps/web/app/(chat)/board/page.tsx @@ -0,0 +1,4 @@ +// BoardPage 只占 /board 路由;看板壳由 ChatHost 组两态。 +export default function BoardPage() { + return null; +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index 73ad11c..b2f78ab 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,54 +1,88 @@ "use client"; +import { BoardClient } from "@codedock/core/board"; import { GitClient } from "@codedock/core/git"; +import { BoardProvider } from "@codedock/views/board"; import { ChatPage, type SessionEngine } from "@codedock/views/chat"; import { GitProvider } from "@codedock/views/git"; import { usePathname, useRouter } from "next/navigation"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import { apiBase } from "@/lib/env"; +import { apiBase, defaultUserId } from "@/lib/env"; import { rememberSession } from "@/lib/session"; -// ChatHost 解析对话路径并把导航收成回调;views 不知道具体 URL。 +// ChatHost 解析对话 / 看板路径并把导航收成回调;views 不知道具体 URL。 export function ChatHost() { const pathname = usePathname(); const router = useRouter(); const parsed = parseChatPath(pathname); - if (parsed.sessionId && parsed.engine === "agent") { - rememberSession(parsed.sessionId); - } - const gitSessionId = parsed.engine === "agent" ? parsed.sessionId : undefined; + const lastSession = useRef<{ id: string; engine: SessionEngine } | null>(null); + // 记住最近一次打开的会话,离开看板时回到那里。 + useEffect(() => { + if (parsed.sessionId && parsed.engine) { + lastSession.current = { id: parsed.sessionId, engine: parsed.engine }; + } + if (parsed.sessionId && parsed.engine === "agent") { + rememberSession(parsed.sessionId); + } + }, [parsed.sessionId, parsed.engine]); + const [boardGitSessionId, setBoardGitSessionId] = useState(); + const routeGitSessionId = parsed.engine === "agent" ? parsed.sessionId : undefined; + const gitSessionId = parsed.board ? boardGitSessionId : routeGitSessionId; const git = useMemo( () => new GitClient({ baseUrl: apiBase, sessionId: gitSessionId }), [gitSessionId], ); + const board = useMemo( + () => new BoardClient({ baseUrl: apiBase, userId: defaultUserId }), + [], + ); return ( - { - const path = pathFor(id, engine); - if (pathname !== path) { - router.push(path); - } - }} - onNewConversation={() => { - if (pathname !== "/") { - router.push("/"); - } - }} - /> + + { + const path = pathFor(id, engine); + if (pathname !== path) { + router.push(path); + } + }} + onNewConversation={() => { + if (pathname !== "/") { + router.push("/"); + } + }} + onOpenBoard={() => { + if (pathname !== "/board") { + router.push("/board"); + } + }} + onLeaveBoard={() => { + const last = lastSession.current; + const path = last ? pathFor(last.id, last.engine) : "/"; + if (pathname !== path) { + router.push(path); + } + }} + /> + ); } -// parseChatPath 先认 /s/claude/:id,再认 /s/c/:id,避免吃掉 Claude 前缀。 -function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine } { +// parseChatPath 先认 /board,再认 /s/claude/:id 与 /s/c/:id。 +function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine; board?: boolean } { + if (pathname === "/board" || pathname.startsWith("/board/")) { + return { board: true }; + } const claude = pathname.match(/^\/s\/claude\/([^/]+)/); if (claude?.[1]) { return { sessionId: decodeURIComponent(claude[1]), engine: "claude" }; diff --git a/docs/architecture.md b/docs/architecture.md index 617f408..9371702 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runt ## 总体架构 ```text -apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient / ClaudeClient) +apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient / ClaudeClient / BoardClient) | v packages/views (Chat 壳 / hooks,无 next/*) @@ -28,18 +28,20 @@ server/internal/handler |-- 用户记忆查看 / 删除 --> pkg/db/sqlite |-- Git HTTP --> pkg/git(本机 CLI,无产品流程) |-- Claude HTTP --> pkg/claude(本机 Claude Code,不落库) + |-- 看板 /works /board --> internal/board(Work / Checkout / Placement / Inbox) | v server/internal/agent |-- sqlc 读写 Run / Turn / 事件 / 用量 / checkpoint |-- 调用 pkg/agent 无状态方法 |-- 先持久化 AgentEvent,再发布 internal/events.Bus - |-- memory:热层目录+专题,冷层按工作区 FTS;Loop 装目录并 IndexMessage + |-- memory:热层目录+专题(含 work 范围),冷层按工作区 FTS;本回合装 user+Work |-- tools:工具定义在本包;New 注入 Ports(Execute 用的外部实现)后 Register | v server/pkg/agent server/pkg/git +server/pkg/github server/pkg/codex server/pkg/claude ``` @@ -53,9 +55,9 @@ CodeDock/ ├── apps/ │ └── web/ # Next.js 路由与平台装配;不解析 SSE ├── packages/ -│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ +│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/ claude/ board/),不要 src/ │ ├── ui/ # 无业务语义;components / lib / styles,不要 src/ -│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ +│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/ claude/ board/),不要 src/ ├── docs/ ├── plugin/example/ # 插件拷贝模板;不改正文、不换向 ├── plugin/redact/ # 脱敏插件;PLUGIN_DIR 指到 plugin/ @@ -63,11 +65,12 @@ CodeDock/ ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ -│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git、Claude Code +│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git、看板、Claude Code │ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 -│ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 +│ │ │ ├── memory/ # 热层目录+专题(user / workspace / work),冷层工作区 FTS 索引 │ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_*、explore +│ │ ├── board/ # Work / Checkout / Info / Placement / Inbox / Packet;不写记忆、不建 worktree │ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE │ │ ├── events/ # 进程内事件总线 │ │ ├── pluginhost/ # go-plugin 宿主:拉进程、Dispatch、Host 白名单 @@ -80,6 +83,7 @@ CodeDock/ │ │ │ └── seam/ # Envelope / Dispatcher / 六个口的类型常量 │ │ ├── plugin/ # 插件 SDK 与 proto;作者只 import 这个包 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 +│ │ ├── github/ # 无状态 gh CLI:issue/pr view 快照;不落 token、不合 PR │ │ ├── claude/ # 无状态 Claude Code 对接;从本机 Claude 读,不落库 │ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型 │ │ └── db/ # Client 与 sqlc 生成代码 @@ -105,7 +109,9 @@ internal/handler -> pkg/db/sqlite.Queries -> pkg/agent # 映射响应、token 统计、Profile 装配 -> pkg/git # 本机 Git CLI 操作 + -> pkg/github # 本机 gh Issue/PR 快照 -> pkg/claude # 本机 Claude Code 对接 + -> internal/board # Work / 看板聚合 / Inbox 转交 -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 @@ -118,12 +124,18 @@ internal/codex 启动本机 `codex app-server --stdio` 排队、草稿、问票、SSE 只放内存 +internal/board + -> pkg/db/sqlite.Queries + -> pkg/agent # 审批 kind 文本 + 不写记忆、不 spawn CLI、不建 worktree + internal/agent -> pkg/db/sqlite.Queries -> pkg/agent -> internal/events -> internal/agent/memory -> internal/agent/tools + -> internal/board # native 读 Placement 与 Packet internal/agent/memory -> pkg/db/sqlite.Queries @@ -162,6 +174,10 @@ pkg/git 不依赖 handler、internal、sqlc 无状态,只 exec 本机 git;不写产品流程 +pkg/github + 不依赖 handler、internal、sqlc + 无状态,只 exec 本机 gh issue/pr view;不落 token + pkg/claude 不依赖 handler、internal、sqlc 无状态,从本机 Claude 读会话 / 实录 / 配置,不落库;不写产品流程 @@ -174,7 +190,7 @@ pkg/codex packages/core 不依赖 React、Next、DOM、process.env、AI SDK - 按业务域拆目录(chat / git / codex / claude),不要 src/ + 按业务域拆目录(chat / git / codex / claude / board),不要 src/ 文件直接落在 packages/core// baseUrl / userId 由调用方注入 @@ -187,13 +203,13 @@ packages/views -> packages/core -> packages/ui 不 import next/* - 按业务域拆目录,与 core 对齐(现有 chat / git / codex / claude) - AgentProvider 在包根注入 client + userId;CodexProvider / ClaudeProvider 注入各自 Client - ChatPage 在新建会话时选择 Local / Codex / Claude;导航用回调 + 按业务域拆目录,与 core 对齐(现有 chat / git / codex / claude / board) + AgentProvider 在包根注入 client + userId;CodexProvider / ClaudeProvider / BoardProvider 注入各自 Client + ChatPage 组会话 / 看板两态;导航用回调 apps/web -> packages/views - -> packages/core # 创建 AgentClient / GitClient / CodexClient / ClaudeClient + -> packages/core # 创建 AgentClient / GitClient / CodexClient / ClaudeClient / BoardClient -> packages/ui # 引入 tokens.css 不直接解析 SSE 或 event type ``` @@ -205,7 +221,8 @@ apps/web 承担大部分接口逻辑: - Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框。`sessions.summary` 在首次用户消息写入,列表与详情返回 -- 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录) +- 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id / work_id,Get/Delete 用 name 默认目录) +- 看板 HTTP(`/works`、`/board`、`/placements`、`/inbox/decision`、`/session-links`):调 `internal/board`。Inbox 按引擎转给已有 `/approvals`、`/claude/...`、`/codex/...` 裁决,不把三引擎问票收成同一种结构。Issue/PR 快照走 `pkg/github` - SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 - Run 的 Start / Continue / Retry / Cancel / Restore 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker。`POST /runs/{id}/restore` 按快照粒度还原工作区。验证熔断单带 `kind=verify`(旧 `evaluate` 单仍可回放),人可 `accept` / `retry` / `abort` @@ -231,13 +248,13 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `internal/agent/memory` -热层是每个 scope 一篇目录(`kind=index`,`name` 固定 `index`)加多篇专题(`kind=topic`)。scope 只有 `user` / `workspace`。冷层是同一 `workspace_id` 下的 context message FTS。与 Loop 独立: +热层是每个 scope 一篇目录(`kind=index`,`name` 固定 `index`)加多篇专题(`kind=topic`)。scope 是 `user` / `workspace` / `work`(`ScopeWork`)。冷层是同一 `workspace_id` 下的 context message FTS。与 Loop 独立: - 类型、`ByteLen`、`IndexOverBudget`、`ClipIndex`(200 行 / 25KB) - Agent 侧 TextMemory 的 Get / Upsert / Delete / List - `SearchMessages` 只查不写;`IndexMessage` 由 Loop 写 message 时调用 -不 import 父包;不解析 Markdown;不负责 Prompt / Context Packet / 对话压缩;不定义 Tool。不放在 `pkg`。不新建 Workspace 业务包,只持有 `workspace_id` 字段。Loop 在新 Session / 对话压缩后装冻结目录(独立 system 消息,不拼进静态 prompt,不入库)。超限目录立刻写入,由 Runtime 后台 `pkg.CompactIndex` 改短盖写,不自动建专题,不改当前 Session 冻结前缀。 +不 import 父包;不解析 Markdown;不负责 Prompt / Context Packet / 对话压缩;不定义 Tool。不放在 `pkg`。不新建 Workspace 业务包,只持有 `workspace_id` 字段。Loop 在新 Session / 对话压缩后装 **user + 当前 Work** 冻结目录(有 placement 时);`workspace` 热层保留但不进本回合。Packet 是独立 system,不入库、不抄进记忆。超限目录立刻写入,由 Runtime 后台 `pkg.CompactIndex` 改短盖写,不自动建专题,不改当前 Session 冻结前缀。未归组会话只能写 user 记忆。 ### `internal/agent/tools` @@ -259,10 +276,18 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 详见 [plugin.md](plugin.md)。 +### `internal/board` + +产品工作流:Work / Info / Placement / Board 聚合 / Inbox 编排 / Packet。同一用户下标题不能重复;新建时标题为空则取最小的空号,从 `未命名(1)` 起。一张卡可挂多路会话。目录绑在会话上,不挂在卡上;只在创建会话时绑定,之后不能改、不能解绑。看板待审批和会话窗口一样一条一条看摘要,选齐同一张票才提交。旧会话不自动建卡;可先聊再补挂。删卡只断开归属,不删会话、不删磁盘。看板只聚合摘要,不加载对话正文。进行中只表示引擎还在执行;等审批单独计,不记成进行中。侧栏同样只把还在执行的会话算进行中。看板开着且有进行中或待审批时会重拉这两项。目录 Git 状态现问 `pkg/git`。不写记忆正文、不 spawn CLI、不建 worktree。 + ### `pkg/git` 无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。 +### `pkg/github` + +无状态本机 `gh` CLI:`issue view` / `pr view` JSON 快照。不关 issue、不合 PR、不落 token。不进 `pkg/agent`,不写产品流程。会话头上的 Issue/PR 由 Handler 写入 `session_issues` / `session_pulls`。 + ### `pkg/claude` 无状态 Claude Code 对接:引擎探测、会话、配置、斜杠命令、附件、回合、实录、审批,以及对本机 Claude 说话。会话 / 实录 / 模型 / 权限档从本机 Claude 读,不落库。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。Handler 直接调用。 @@ -306,7 +331,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 ### `packages/ui` @@ -320,11 +345,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage` 三栏(会话列表、对话、右侧窗口栏,左右栏可收起),瀑布、审批、prompt;新建会话可选 Local / Codex / Claude。对话里的 Plan 收成条目,点开后在右侧窗口显示。每次 Run 改过的文件集中在一个框里(时间线与文件窗口共用),点选后右侧看正文。右侧窗口栏可新建 / 关闭 Plan、文件、Git,后续窗口种类往这里加。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`;由 `ChatPage` 右侧窗口组合,不单独做业务页。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`。Claude 在 `claude/`:`ClaudeProvider` 只注入 `ClaudeClient`。二者都由 `ChatPage` 组合。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage` 两态。会话模式是三栏(左侧按 Work 分组、中间对话、右侧 Plan / 文件 / Git)。看板模式藏中间对话,左上角仍是同一排 logo 和切换按钮,旁边是「新建分组」(不填标题,空号由服务端取);再点切换按钮回到会话列表。列头标题可直接改。中间是横向 Work 列(列宽固定、列内纵向滚动,横向滑到末尾再挂下一列)。来回切换都从打开按钮长出黑点盖住屏幕,再褪开露出另一态。看板顶栏不放「新对话」。完整对话用可拖悬浮窗,右侧窗口栏不加 `chat` 种类。新建会话可选 Local / Codex / Claude,可先聊再补挂。发出第一条消息前就能打开设置;保存 Issue 或 PR 链接即创建会话,不必先发消息,关掉新建窗仍不建会话。链接随 Packet 加在对话前面。看板新建悬浮窗同样有这个按钮。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` / `boardMode` 与导航回调。Git 在 `git/`:`GitProvider` 只注入 `GitClient`。Codex / Claude 同上。看板在 `board/`:`BoardProvider` 只注入 `BoardClient`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包 `AgentProvider` / `GitProvider` / `CodexProvider` / `ClaudeProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。对话页装配 `GitClient` 给右侧 Git 窗口。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient` / `BoardClient`、包 `AgentProvider` / `GitProvider` / `CodexProvider` / `ClaudeProvider` / `BoardProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。对话页装配 `GitClient` 给右侧 Git 窗口。看板上打开 Local 悬浮会话时,Git 窗口用这路会话的冻结目录。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`,看板走 `/board`,`/s/...` 仍是会话模式。 ## 组装关系 diff --git a/packages/core/board/client.test.ts b/packages/core/board/client.test.ts new file mode 100644 index 0000000..1d22c62 --- /dev/null +++ b/packages/core/board/client.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { BoardClient, BoardClientError } from "./client.ts"; + +test("BoardClient hits /works /board and inbox routes", async () => { + const calls: { url: string; method: string; body?: unknown }[] = []; + const client = new BoardClient({ + baseUrl: "http://api.test/", + userId: "u1", + tenantId: "t1", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ url, method, body: raw ? JSON.parse(raw) : undefined }); + if (url.includes("/board?") && method === "GET") { + return json({ board: { cards: [{ work: { id: "w1", title: "修登录" }, sessions: [] }], ungrouped: [] } }); + } + if (url.endsWith("/works") && method === "POST") { + return json({ work: { id: "w1", title: "修登录", user_id: "u1", tenant_id: "t1" } }); + } + if (url.includes("/works/w1/sessions")) { + return json({ + placement: { engine: "native", session_id: "s1", work_id: "w1", checkout: "" }, + session_id: "s1", + engine: "agent", + }); + } + if (url.includes("/works/w1/inbox")) { + return json({ items: [{ engine: "agent", session_id: "s1", ticket_id: "a1", summary: "tool" }] }); + } + if (url.includes("/inbox/decision")) { + return json({ ok: true }); + } + if (url.includes("/placements?")) { + return json({ placements: [{ engine: "native", session_id: "s1", work_id: "w1", checkout: "" }] }); + } + return json({ ok: true }); + }, + }); + + const work = await client.createWork("修登录"); + assert.equal(work.id, "w1"); + const board = await client.getBoard(); + assert.equal(board.cards[0]?.work.title, "修登录"); + const started = await client.startSession("w1", { engine: "agent", kind: "talk" }); + assert.equal(started.session_id, "s1"); + const items = await client.listInbox("w1"); + assert.equal(items[0]?.ticket_id, "a1"); + await client.decideInbox({ engine: "agent", ticket_id: "a1", status: "approved", actor_id: "u1" }); + const places = await client.listPlacements(); + assert.equal(places[0]?.engine, "agent"); + + assert.equal(calls[0]?.url, "http://api.test/works"); + assert.match(String(calls[1]?.url), /user_id=u1/); + assert.equal(calls.some((item) => item.url.endsWith("/inbox/decision")), true); + const saved = await client.replaceLinks("agent", "s1", ["https://github.com/acme/app/pull/8"]); + assert.equal(saved.pulls.length, 0); + assert.equal(calls.at(-1)?.method, "PUT"); + assert.deepEqual(calls.at(-1)?.body, { links: ["https://github.com/acme/app/pull/8"] }); + + await assert.rejects( + () => + new BoardClient({ + baseUrl: "http://api.test", + userId: "u1", + fetch: async () => json({ error: "no" }, 400), + }).createWork("x"), + (err: unknown) => err instanceof BoardClientError && err.status === 400, + ); +}); + +// json 造一条假响应。 +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/packages/core/board/client.ts b/packages/core/board/client.ts new file mode 100644 index 0000000..8956da4 --- /dev/null +++ b/packages/core/board/client.ts @@ -0,0 +1,299 @@ +import type { + BoardEngine, + BoardView, + Card, + Checkout, + CheckoutKind, + InboxDecideRequest, + InboxItem, + IssueSnap, + Packet, + Placement, + PullSnap, + PutLinkRequest, + SessionLinks, + StartWorkSessionRequest, + Work, + WorkInfo, +} from "./types.ts"; + +export class BoardClientError extends Error { + readonly status: number; + + // 记录看板 HTTP 失败状态。 + constructor(status: number, message: string) { + super(message); + this.name = "BoardClientError"; + this.status = status; + } +} + +export type BoardClientOptions = { + baseUrl: string; + userId: string; + tenantId?: string; + fetch?: typeof fetch; +}; + +export class BoardClient { + readonly baseUrl: string; + readonly userId: string; + readonly tenantId: string; + private readonly fetchImpl: typeof fetch; + + // 组装看板 HTTP 客户端;tenant 空则 default。 + constructor(options: BoardClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.userId = options.userId; + this.tenantId = options.tenantId?.trim() || "default"; + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + // listWorks 列出当前用户的卡。 + async listWorks(): Promise { + const body = (await this.request(`/works${this.userQuery()}`)) as { works?: Work[] }; + return body.works ?? []; + } + + // createWork 建一张卡。 + async createWork(title: string): Promise { + const body = (await this.request("/works", { + method: "POST", + json: { user_id: this.userId, tenant_id: this.tenantId, title }, + })) as { work: Work }; + return body.work; + } + + // updateWork 改卡片标题。 + async updateWork(workId: string, title: string): Promise { + const body = (await this.request(`/works/${workId}`, { + method: "PATCH", + json: { title }, + })) as { work: Work }; + return body.work; + } + + // deleteWork 删卡并断开归属。 + async deleteWork(workId: string): Promise { + await this.request(`/works/${workId}`, { method: "DELETE" }); + } + + // attachCheckout 把已存在目录挂到卡上。 + async attachCheckout(workId: string, path: string, kind: CheckoutKind = "folder"): Promise { + const body = (await this.request(`/works/${workId}/checkouts`, { + method: "POST", + json: { path, kind }, + })) as { checkout: Checkout }; + return body.checkout; + } + + // listCheckouts 列出卡上目录。 + async listCheckouts(workId: string): Promise { + const body = (await this.request(`/works/${workId}/checkouts`)) as { checkouts?: Checkout[] }; + return body.checkouts ?? []; + } + + // detachCheckout 卸下目录。 + async detachCheckout(workId: string, path: string): Promise { + await this.request(`/works/${workId}/checkouts`, { + method: "DELETE", + json: { path }, + }); + } + + // getInfo 读 Work 级或目录级说明。 + async getInfo(workId: string, checkout = ""): Promise { + const query = new URLSearchParams(); + if (checkout) { + query.set("checkout", checkout); + } + const suffix = query.toString() ? `?${query}` : ""; + const body = (await this.request(`/works/${workId}/info${suffix}`)) as { info: WorkInfo }; + return body.info; + } + + // putInfo 覆盖写入说明。 + async putInfo(workId: string, bodyText: string, checkout = ""): Promise { + const body = (await this.request(`/works/${workId}/info`, { + method: "PUT", + json: { checkout, body: bodyText }, + })) as { info: WorkInfo }; + return body.info; + } + + // startSession 从一张卡开问答或目录会话。 + async startSession( + workId: string, + req: StartWorkSessionRequest, + ): Promise<{ placement: Placement; session_id: string; engine: BoardEngine }> { + return (await this.request(`/works/${workId}/sessions`, { + method: "POST", + json: { + engine: req.engine, + kind: req.kind, + checkout: req.checkout ?? "", + user_id: this.userId, + tenant_id: this.tenantId, + agent_id: req.agent_id, + }, + })) as { placement: Placement; session_id: string; engine: BoardEngine }; + } + + // attachPlacement 把未归组会话补挂到卡上。 + async attachPlacement(workId: string, engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/works/${workId}/placements`, { + method: "POST", + json: { engine, session_id: sessionId }, + })) as { placement: Placement }; + return publicPlacement(body.placement); + } + + // listPlacements 列出用户所有归属。 + async listPlacements(): Promise { + const body = (await this.request(`/placements${this.userQuery()}`)) as { placements?: Placement[] }; + return (body.placements ?? []).map(publicPlacement); + } + + // getPlacement 读一路会话的归属。 + async getPlacement(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/placements/${engine}/${sessionId}`)) as { placement: Placement }; + return publicPlacement(body.placement); + } + + // deletePlacement 断开归属。 + async deletePlacement(engine: BoardEngine, sessionId: string): Promise { + await this.request(`/placements/${engine}/${sessionId}`, { method: "DELETE" }); + } + + // bindDirectory 在创建会话时记下目录。已有目录不能更换。 + async bindDirectory(engine: BoardEngine, sessionId: string, path: string): Promise { + const body = (await this.request(`/session-directories/${engine}/${sessionId}`, { + method: "PUT", + json: { path }, + })) as { path?: string }; + return body.path ?? ""; + } + + // getBoard 聚合横向看板。 + async getBoard(): Promise { + const body = (await this.request(`/board${this.userQuery()}`)) as { board?: BoardView }; + return { + cards: body.board?.cards ?? [], + ungrouped: body.board?.ungrouped ?? [], + }; + } + + // getCard 聚合一张卡。 + async getCard(workId: string): Promise { + const body = (await this.request(`/board/cards/${workId}`)) as { card: Card }; + return body.card; + } + + // listInbox 扫该卡下三引擎待批。 + async listInbox(workId: string): Promise { + const body = (await this.request(`/works/${workId}/inbox`)) as { items?: InboxItem[] }; + return body.items ?? []; + } + + // decideInbox 按引擎把裁决转给已有审批。 + async decideInbox(req: InboxDecideRequest): Promise { + await this.request("/inbox/decision", { + method: "POST", + json: req, + }); + } + + // getPacket 读开回合用的只读 Packet。 + async getPacket(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/placements/${engine}/${sessionId}/packet`)) as { packet: Packet }; + return body.packet; + } + + // getLinks 读会话头上的 Issue/PR。 + async getLinks(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}`)) as { links?: SessionLinks }; + return { issue: body.links?.issue ?? null, pulls: body.links?.pulls ?? [] }; + } + + // replaceLinks 用一批链接收掉会话上的 Issue/PR,种类由链接路径决定。 + async replaceLinks(engine: BoardEngine, sessionId: string, links: string[]): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}`, { + method: "PUT", + json: { links }, + })) as { links?: SessionLinks }; + return { issue: body.links?.issue ?? null, pulls: body.links?.pulls ?? [] }; + } + + // putIssue 用 gh 拉 Issue 快照并挂到会话头。 + async putIssue(engine: BoardEngine, sessionId: string, req: PutLinkRequest): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}/issue`, { + method: "PUT", + json: req, + })) as { issue: IssueSnap }; + return body.issue; + } + + // deleteIssue 去掉会话上的 Issue。 + async deleteIssue(engine: BoardEngine, sessionId: string): Promise { + await this.request(`/session-links/${engine}/${sessionId}/issue`, { method: "DELETE" }); + } + + // putPull 用 gh 拉 PR 快照并挂到会话头。 + async putPull(engine: BoardEngine, sessionId: string, req: PutLinkRequest): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}/pulls`, { + method: "POST", + json: req, + })) as { pull: PullSnap }; + return body.pull; + } + + // deletePull 去掉会话上的一条 PR。 + async deletePull(engine: BoardEngine, sessionId: string, number: number, repo = ""): Promise { + const query = repo ? `?repo=${encodeURIComponent(repo)}` : ""; + await this.request(`/session-links/${engine}/${sessionId}/pulls/${number}${query}`, { method: "DELETE" }); + } + + // userQuery 带上当前用户,给列表接口用。 + private userQuery(): string { + const search = new URLSearchParams({ user_id: this.userId, tenant_id: this.tenantId }); + return `?${search.toString()}`; + } + + // request 发 JSON 请求,失败时抛 BoardClientError。 + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers, + body: init.json !== undefined ? JSON.stringify(init.json) : init.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new BoardClientError(res.status, message); + } + return parsed; + } +} + +// publicPlacement 把库内 native 收成前端 agent。 +function publicPlacement(place: Placement): Placement { + return { + ...place, + engine: place.engine === "native" ? "agent" : place.engine, + }; +} diff --git a/packages/core/board/index.ts b/packages/core/board/index.ts new file mode 100644 index 0000000..7259f8a --- /dev/null +++ b/packages/core/board/index.ts @@ -0,0 +1,21 @@ +export { BoardClient, BoardClientError, type BoardClientOptions } from "./client.ts"; +export type { + BoardEngine, + BoardView, + Card, + Checkout, + CheckoutKind, + DirView, + InboxDecideRequest, + InboxItem, + IssueSnap, + Packet, + Placement, + PullSnap, + PutLinkRequest, + SessionLinks, + SessionView, + StartWorkSessionRequest, + Work, + WorkInfo, +} from "./types.ts"; diff --git a/packages/core/board/types.ts b/packages/core/board/types.ts new file mode 100644 index 0000000..6fee3f0 --- /dev/null +++ b/packages/core/board/types.ts @@ -0,0 +1,140 @@ +export type BoardEngine = "agent" | "claude" | "codex"; + +export type CheckoutKind = "folder" | "primary" | "worktree"; + +export type Work = { + id: string; + tenant_id: string; + user_id: string; + title: string; + created_at: string; + updated_at: string; +}; + +export type WorkInfo = { + work_id: string; + checkout: string; + body: string; + updated_at: string; +}; + +export type Checkout = { + work_id: string; + path: string; + kind: CheckoutKind; +}; + +export type Placement = { + engine: BoardEngine | "native"; + session_id: string; + work_id: string; + checkout: string; +}; + +export type IssueSnap = { + engine: string; + session_id: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + state: string; + updated_at: string; +}; + +export type PullSnap = { + engine: string; + session_id: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + state: string; + updated_at: string; +}; + +export type SessionLinks = { + issue?: IssueSnap | null; + pulls: PullSnap[]; +}; + +export type SessionView = { + engine: BoardEngine; + session_id: string; + summary: string; + checkout: string; + branch?: string; + dirty?: boolean; + running: boolean; + pending: number; + updated_at: string; +}; + +export type DirView = { + path: string; + kind: string; + info: WorkInfo; + branch: string; + dirty: boolean; + shared_writers: string[]; +}; + +export type Card = { + work: Work; + info: WorkInfo; + dirs: DirView[]; + sessions: SessionView[]; + running: number; + pending: number; +}; + +export type BoardView = { + cards: Card[]; + ungrouped: SessionView[]; +}; + +export type InboxItem = { + engine: BoardEngine; + session_id: string; + ticket_id: string; + summary: string; + payload: unknown; +}; + +export type Packet = { + work_info: WorkInfo; + dir_info?: WorkInfo | null; + issue?: IssueSnap | null; + pulls: PullSnap[]; + text: string; +}; + +export type StartWorkSessionRequest = { + engine: BoardEngine; + kind: "talk" | "dir"; + checkout?: string; + agent_id?: string; +}; + +export type InboxDecideRequest = { + engine: BoardEngine; + ticket_id: string; + decisions?: { tool_call_id: string; status: string; reason?: string }[]; + status?: string; + scope?: string; + actor_id?: string; + reason?: string; + override?: string; + approved?: boolean; + choice?: string; + values?: string[]; + answers?: Record; +}; + +export type PutLinkRequest = { + repo?: string; + number?: number; + ref?: string; +}; diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 1f13ef0..8b4e9f6 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -83,6 +83,8 @@ export interface Session { active_run_id?: string; /** 当前 active Run 已中断且 Worker 不在跑,界面才应显示「恢复」。 */ needs_recover?: boolean; + /** 当前 active Run 还在执行。等审批、已结束、需要恢复时为 false;缺省表示这条响应没带。 */ + executing?: boolean; last_event_seq: number; compaction_seq: number; summary?: string; diff --git a/packages/core/claude/types.ts b/packages/core/claude/types.ts index a42b0cb..51b7718 100644 --- a/packages/core/claude/types.ts +++ b/packages/core/claude/types.ts @@ -29,6 +29,8 @@ export type ClaudeSession = { claude_session_id: string; title: string; active_turn_id: string; + /** 有进行中的回合且没有待审批。缺省表示这条响应没带。 */ + running?: boolean; archived: boolean; /** 本机实录第一条时间,Unix 秒。 */ created_at?: number; diff --git a/packages/core/codex/types.ts b/packages/core/codex/types.ts index 1a6c7f0..d7517c1 100644 --- a/packages/core/codex/types.ts +++ b/packages/core/codex/types.ts @@ -45,6 +45,8 @@ export type Session = { preview?: string; cwd?: string; active_turn_id?: string; + /** 有进行中的回合且没有待审批。缺省表示这条响应没带。 */ + running?: boolean; archived: boolean; ephemeral?: boolean; created_at?: number; diff --git a/packages/core/index.ts b/packages/core/index.ts index ce981fb..979d8d3 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -36,6 +36,17 @@ export { export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; export { CodexClient, CodexClientError, type CodexClientOptions } from "./codex/index.ts"; export { ClaudeClient, ClaudeClientError, type ClaudeClientOptions } from "./claude/index.ts"; +export { BoardClient, BoardClientError, type BoardClientOptions } from "./board/index.ts"; +export type { + BoardEngine, + BoardView, + Card, + InboxItem, + Placement, + SessionLinks, + SessionView, + Work, +} from "./board/index.ts"; export type { ApprovalAsk, AskAnswer, diff --git a/packages/core/package.json b/packages/core/package.json index 6e6f931..7ed925b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,10 +8,11 @@ "./chat": "./chat/index.ts", "./git": "./git/index.ts", "./codex": "./codex/index.ts", - "./claude": "./claude/index.ts" + "./claude": "./claude/index.ts", + "./board": "./board/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/files.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts arch.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/files.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts board/client.test.ts arch.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css index 4c52487..ec03ab0 100644 --- a/packages/ui/styles/tokens.css +++ b/packages/ui/styles/tokens.css @@ -58,10 +58,58 @@ } } +/* 待审批:暖色呼吸。和进行中的扫光分开,一眼能分出是在等人。 */ +.live-status-waiting { + animation: live-status-wait 1.8s ease-in-out infinite; +} + +@keyframes live-status-wait { + 0%, + 100% { + color: color-mix(in oklab, #f59e0b 50%, var(--muted-foreground)); + text-shadow: none; + } + 50% { + color: #fbbf24; + text-shadow: 0 0 8px color-mix(in oklab, #f59e0b 65%, transparent); + } +} + @media (prefers-reduced-motion: reduce) { .live-status-active { animation: none; background: none; color: inherit; } + + .live-status-waiting { + animation: none; + color: #fbbf24; + text-shadow: none; + } +} + +/* 进行中的会话:图标一圈呼吸光,标题沿用上面的扫光。 */ +.session-live-mark { + border-radius: 5px; + animation: session-live-glow 1.4s ease-in-out infinite; +} + +@keyframes session-live-glow { + 0%, + 100% { + box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 28%, transparent); + } + 50% { + box-shadow: + 0 0 0 1px color-mix(in oklab, var(--foreground) 80%, transparent), + 0 0 8px color-mix(in oklab, var(--foreground) 45%, transparent); + } +} + +@media (prefers-reduced-motion: reduce) { + .session-live-mark { + animation: none; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 55%, transparent); + } } diff --git a/packages/views/board/board-grid.tsx b/packages/views/board/board-grid.tsx new file mode 100644 index 0000000..39e8e2c --- /dev/null +++ b/packages/views/board/board-grid.tsx @@ -0,0 +1,137 @@ +"use client"; + +import type { BoardView } from "@codedock/core/board"; +import { useEffect, useRef, useState } from "react"; + +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { BOARD_COL_WIDTH, boardColumns, revealColumnCount, visibleColumnCount } from "./layout.ts"; +import { useBoard } from "./provider.tsx"; +import { WorkColumn } from "./work-column.tsx"; + +// BoardGrid 横向 Work 列:列宽固定,滑到右端再挂下一列。 +export function BoardGrid({ + onOpenSession, + onDraftSession, + onArchived, + revision = 0, + notice = null, +}: { + onOpenSession: (id: string, engine: SessionEngine) => void; + /** 在看板上弹出输入窗;发出消息才建会话。 */ + onDraftSession?: (workId: string, engine: SessionEngine, directory: string, title: string) => void; + onArchived?: (id: string, engine: SessionEngine) => void; + /** 有新会话挂上后加一,用来重拉列。 */ + revision?: number; + /** 顶栏操作失败时显示在列上方,例如新建分组。 */ + notice?: string | null; +}) { + const { client } = useBoard(); + const [view, setView] = useState({ cards: [], ungrouped: [] }); + const [width, setWidth] = useState(720); + const [shown, setShown] = useState(() => visibleColumnCount(720)); + const [loadError, setLoadError] = useState(null); + const frameRef = useRef(null); + const scrollerRef = useRef(null); + const sentinelRef = useRef(null); + + // reload 只拉摘要,不加载对话正文。 + const reload = async () => { + const next = await client.getBoard(); + setView(next); + }; + + useEffect(() => { + void reload() + .then(() => setLoadError(null)) + .catch((err: unknown) => { + setLoadError(err instanceof Error ? err.message : "无法加载看板"); + }); + }, [client, revision]); + + const columns = boardColumns(view); + const live = columns.some((column) => + column.sessions.some((session) => session.running || session.pending > 0), + ); + + // 进行中或待审批时重拉摘要,结束和问票出现后状态跟着变。 + useEffect(() => { + if (!live) { + return; + } + const timer = window.setInterval(() => { + void client.getBoard().then(setView).catch(() => undefined); + }, 3000); + return () => window.clearInterval(timer); + }, [client, live]); + + useEffect(() => { + const node = frameRef.current; + if (!node || typeof ResizeObserver === "undefined") { + return; + } + const frame = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect.width ?? 0; + if (next > 0) { + setWidth(next); + } + }); + frame.observe(node); + setWidth(node.clientWidth || 720); + return () => frame.disconnect(); + }, []); + + const batch = visibleColumnCount(width); + const visible = columns.slice(0, shown); + const columnCount = useRef(columns.length); + + useEffect(() => { + setShown((current) => Math.max(current, batch)); + }, [batch]); + + useEffect(() => { + const previous = columnCount.current; + columnCount.current = columns.length; + if (previous > 1 && columns.length > previous) { + setShown((current) => current + columns.length - previous); + } + }, [columns.length]); + + useEffect(() => { + const root = scrollerRef.current; + const sentinel = sentinelRef.current; + if (!root || !sentinel || shown >= columns.length) { + return; + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShown((current) => revealColumnCount(current, columns.length, 1)); + } + }, { root, rootMargin: "0px 48px 0px 0px" }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [columns.length, shown]); + + return ( +
+ {notice || loadError ?

{notice || loadError}

: null} +
+ {visible.map((column) => ( +
+ +
+ ))} + {shown < columns.length ?
: null} +
+
+ ); +} diff --git a/packages/views/board/group.test.ts b/packages/views/board/group.test.ts new file mode 100644 index 0000000..19c8083 --- /dev/null +++ b/packages/views/board/group.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { groupSessionsByWork, sessionKey } from "./group.ts"; + +test("groupSessionsByWork groups by work then time, leftover goes ungrouped", () => { + const groups = groupSessionsByWork( + [ + { id: "s-old", engine: "agent", updated_at: "2026-01-01T00:00:00Z", summary: "旧" }, + { id: "s-new", engine: "agent", updated_at: "2026-02-01T00:00:00Z", summary: "新" }, + { id: "s-free", engine: "claude", updated_at: "2026-03-01T00:00:00Z", summary: "闲" }, + ], + [ + { engine: "agent", session_id: "s-old", work_id: "w1", checkout: "" }, + { engine: "native", session_id: "s-new", work_id: "w1", checkout: "/repo" }, + ], + [ + { + id: "w1", + tenant_id: "t", + user_id: "u", + title: "修登录", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-02-01T00:00:00Z", + }, + ], + ); + assert.equal(groups.length, 2); + assert.equal(groups[0]?.id, "w1"); + assert.deepEqual( + groups[0]?.sessions.map((item) => item.id), + ["s-new", "s-old"], + ); + assert.equal(groups[1]?.id, null); + assert.equal(groups[1]?.title, "未分组"); + assert.equal(groups[1]?.sessions[0]?.id, "s-free"); + assert.equal(sessionKey("native", "s-new"), "agent:s-new"); +}); + +test("groupSessionsByWork orders works by the newest session", () => { + const groups = groupSessionsByWork( + [ + { id: "quiet", engine: "agent", updated_at: "2026-01-02T00:00:00Z" }, + { id: "busy", engine: "agent", updated_at: "2026-04-01T00:00:00Z" }, + ], + [ + { engine: "agent", session_id: "quiet", work_id: "w-old", checkout: "" }, + { engine: "agent", session_id: "busy", work_id: "w-new", checkout: "" }, + ], + [ + { + id: "w-old", + tenant_id: "t", + user_id: "u", + title: "旧卡", + created_at: "2026-03-01T00:00:00Z", + updated_at: "2026-03-01T00:00:00Z", + }, + { + id: "w-new", + tenant_id: "t", + user_id: "u", + title: "新卡", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ], + ); + assert.deepEqual( + groups.map((group) => group.id), + ["w-new", "w-old", null], + ); +}); diff --git a/packages/views/board/group.ts b/packages/views/board/group.ts new file mode 100644 index 0000000..1feead9 --- /dev/null +++ b/packages/views/board/group.ts @@ -0,0 +1,81 @@ +import type { Placement, Work } from "@codedock/core/board"; + +export type GroupedSession = { + id: string; + engine?: string; + updated_at: string; + summary?: string; +}; + +export type WorkGroup = { + id: string | null; + title: string; + updatedAt: string; + sessions: T[]; +}; + +// sessionKey 用引擎+会话拼侧栏/看板主键。 +export function sessionKey(engine: string | undefined, sessionId: string): string { + return `${engine === "native" ? "agent" : engine ?? "agent"}:${sessionId}`; +} + +// groupSessionsByWork 先按 Work 再按时间聚会话;未挂卡的进未归组。 +export function groupSessionsByWork( + sessions: T[], + placements: Placement[], + works: Work[], +): WorkGroup[] { + const bySession = new Map(); + for (const place of placements) { + bySession.set(sessionKey(place.engine, place.session_id), place.work_id); + } + const buckets = new Map(); + for (const work of works) { + buckets.set(work.id, []); + } + buckets.set(null, []); + for (const session of sessions) { + const workId = bySession.get(sessionKey(session.engine, session.id)) ?? null; + const list = buckets.get(workId) ?? buckets.get(null); + if (!list) { + continue; + } + if (!buckets.has(workId)) { + buckets.set(null, list); + } + list.push(session); + } + const ranked = [...works].sort((left, right) => { + const leftAt = newestAt(buckets.get(left.id) ?? [], left.updated_at); + const rightAt = newestAt(buckets.get(right.id) ?? [], right.updated_at); + return leftAt < rightAt ? 1 : leftAt > rightAt ? -1 : 0; + }); + const groups: WorkGroup[] = ranked.map((work) => { + const sessions = sortSessions(buckets.get(work.id) ?? []); + return { + id: work.id, + title: work.title || "未命名", + updatedAt: newestAt(sessions, work.updated_at), + sessions, + }; + }); + const ungrouped = sortSessions(buckets.get(null) ?? []); + groups.push({ id: null, title: "未分组", updatedAt: ungrouped[0]?.updated_at ?? "", sessions: ungrouped }); + return groups; +} + +// sortSessions 组内按更新时间倒序,新的在前。 +function sortSessions(sessions: T[]): T[] { + return sessions.slice().sort((left, right) => (left.updated_at < right.updated_at ? 1 : left.updated_at > right.updated_at ? -1 : 0)); +} + +// newestAt 取会话里最晚的更新时间,没有会话时用卡片自己的时间。 +function newestAt(sessions: GroupedSession[], fallback: string): string { + let best = fallback; + for (const session of sessions) { + if (session.updated_at > best) { + best = session.updated_at; + } + } + return best; +} diff --git a/packages/views/board/inbox-actions.tsx b/packages/views/board/inbox-actions.tsx new file mode 100644 index 0000000..f46ab62 --- /dev/null +++ b/packages/views/board/inbox-actions.tsx @@ -0,0 +1,257 @@ +"use client"; + +import type { InboxItem } from "@codedock/core/board"; +import { Button } from "@codedock/ui"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useAgent } from "../provider.tsx"; +import { useBoard } from "./provider.tsx"; + +type Choice = "approved" | "denied"; + +type ToolPage = { + id: string; + name: string; + detail: string; +}; + +// InboxActions 在列内按条审批。同一张问票里的工具一次只看一条摘要,选齐才提交。 +export function InboxActions({ + items, + onDone, +}: { + items: InboxItem[]; + onDone?: () => Promise | void; +}) { + if (items.length === 0) { + return null; + } + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +// InboxTicket 一次展示一条工具摘要,通过或拒绝后再看下一条。 +function InboxTicket({ + item, + onDone, +}: { + item: InboxItem; + onDone?: () => Promise | void; +}) { + const { client } = useBoard(); + const { userId } = useAgent(); + const pages = useMemo(() => toolPages(item), [item]); + const batched = useMemo(() => hasToolCalls(item), [item]); + const [index, setIndex] = useState(0); + const [choices, setChoices] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const page = pages[Math.min(index, Math.max(pages.length - 1, 0))]; + if (!page) { + return null; + } + const choice = choices[page.id]; + + // choose 记下当前这一条。同票里还有没选的就翻过去;都选完才按引擎提交。 + const choose = async (status: Choice) => { + const nextChoices = { ...choices, [page.id]: status }; + setChoices(nextChoices); + setError(null); + const missing = pages.findIndex((entry) => !nextChoices[entry.id]); + if (missing >= 0) { + const later = pages.findIndex((entry, cursor) => cursor > index && !nextChoices[entry.id]); + setIndex(later >= 0 ? later : missing); + return; + } + setSubmitting(true); + try { + const engine = item.engine; + const picked = pages.map((entry) => ({ + id: entry.id, + status: nextChoices[entry.id] ?? status, + })); + if (engine === "agent" && batched) { + await client.decideInbox({ + engine, + ticket_id: item.ticket_id, + decisions: picked.map((entry) => ({ tool_call_id: entry.id, status: entry.status })), + scope: "once", + actor_id: userId, + }); + } else if (engine === "agent") { + await client.decideInbox({ + engine, + ticket_id: item.ticket_id, + status: picked[0]?.status ?? status, + scope: "once", + actor_id: userId, + }); + } else { + await client.decideInbox({ + engine, + ticket_id: item.ticket_id, + approved: (picked[0]?.status ?? status) === "approved", + scope: "once", + }); + } + await onDone?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "审批失败"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {pages.length > 1 ? ( +
+ 一条一条审 +
+ + + {index + 1} / {pages.length} + + +
+
+ ) : null} +
+

{page.name}

+ {choiceLabel(choice)} +
+ {page.detail ? ( +
+          {page.detail}
+        
+ ) : null} +
+ + +
+ {error ?

{error}

: null} +
+ ); +} + +// choiceLabel 用短字标出这一条还没选、已通过或已拒绝。 +function choiceLabel(choice?: Choice): string { + if (choice === "approved") { + return "已通过"; + } + if (choice === "denied") { + return "已拒绝"; + } + return "待审"; +} + +// hasToolCalls 判断这张票是不是一批要分开裁的工具。 +function hasToolCalls(item: InboxItem): boolean { + const payload = asRecord(item.payload); + return Array.isArray(payload?.tool_calls) && payload.tool_calls.some((call) => text(asRecord(call)?.id)); +} + +// toolPages 把一张问票拆成一条工具一页。没有工具列表时整票算一页。 +function toolPages(item: InboxItem): ToolPage[] { + const payload = asRecord(item.payload); + const calls = Array.isArray(payload?.tool_calls) ? payload.tool_calls : []; + const pages: ToolPage[] = []; + for (const call of calls) { + const row = asRecord(call); + const id = text(row?.id); + if (!id) { + continue; + } + pages.push({ + id, + name: text(row?.name) || "工具", + detail: argumentDetail(row?.arguments), + }); + } + if (pages.length > 0) { + return pages; + } + const command = text(payload?.command); + const prompt = text(payload?.prompt); + const paths = Array.isArray(payload?.paths) ? payload.paths.map(text).filter(Boolean).join("\n") : ""; + const detail = [command, prompt, paths].filter(Boolean).join("\n"); + const name = text(payload?.kind) || item.summary || "审批"; + return [{ id: item.ticket_id, name: name === "tools" ? item.summary || "审批" : name, detail }]; +} + +// argumentDetail 抽出命令、路径或搜索词。没有这些字段时收成短 JSON。 +function argumentDetail(value: unknown): string { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + return argumentDetail(JSON.parse(trimmed) as unknown); + } catch { + return trimmed; + } + } + const row = asRecord(value); + if (!row) { + return ""; + } + const command = text(row.command); + if (command) { + return command; + } + const path = text(row.path); + const pattern = text(row.pattern); + if (path && pattern) { + return `${pattern} ${path}`; + } + if (path) { + return path; + } + if (pattern) { + return pattern; + } + const compact = JSON.stringify(row); + return compact === "{}" ? "" : compact; +} + +// asRecord 只接受普通对象。 +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +// text 取出非空字符串。 +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/packages/views/board/index.ts b/packages/views/board/index.ts new file mode 100644 index 0000000..570333f --- /dev/null +++ b/packages/views/board/index.ts @@ -0,0 +1,5 @@ +export { BoardGrid } from "./board-grid.tsx"; +export { BoardProvider, useBoard } from "./provider.tsx"; +export { groupSessionsByWork, sessionKey, type WorkGroup } from "./group.ts"; +export { boardColumns, revealColumnCount, visibleColumnCount } from "./layout.ts"; +export { SessionFloat, type FloatSession } from "./session-float.tsx"; diff --git a/packages/views/board/info-editor.tsx b/packages/views/board/info-editor.tsx new file mode 100644 index 0000000..7fd8e77 --- /dev/null +++ b/packages/views/board/info-editor.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { Button } from "@codedock/ui"; +import { useEffect, useRef, useState, type ComponentType } from "react"; +import { createPortal } from "react-dom"; + +type MarkdownEditor = ComponentType<{ + value?: string; + height?: number | string; + preview?: "live" | "edit" | "preview"; + visibleDragbar?: boolean; + autoFocus?: boolean; + commands?: unknown[]; + extraCommands?: unknown[]; + onChange?: (value?: string) => void; +}>; + +// InfoEditor 弹出 @uiw/react-md-editor,用来改这张卡的说明。 +export function InfoEditor({ + open, + value, + onClose, + onSave, +}: { + open: boolean; + value: string; + onClose: () => void; + onSave: (next: string) => Promise; +}) { + const [draft, setDraft] = useState(value); + const valueRef = useRef(value); + valueRef.current = value; + const [Editor, setEditor] = useState(null); + const [commands, setCommands] = useState<{ main: unknown[]; extra: unknown[] } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + setBusy(false); + return; + } + setDraft(valueRef.current); + setError(null); + let cancelled = false; + void Promise.all([import("@uiw/react-md-editor"), import("@uiw/react-md-editor/commands-cn")]).then( + ([editorMod, cn]) => { + if (cancelled) { + return; + } + setEditor(() => editorMod.default as MarkdownEditor); + setCommands({ main: cn.getCommands(), extra: cn.getExtraCommands() }); + }, + (err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "编辑器加载失败"); + } + }, + ); + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busy) { + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [busy, onClose, open]); + + if (!open || typeof document === "undefined") { + return null; + } + + // save 写回说明并关掉编辑框。 + const save = async () => { + setBusy(true); + setError(null); + try { + await onSave(draft); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + }; + + return createPortal( +
{ + if (!busy) { + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+

+ 编辑说明 +

+
+
+ {Editor && commands ? ( + setDraft(next ?? "")} + /> + ) : ( +

{error ?? "正在打开编辑器"}

+ )} +
+ {error && Editor ?

{error}

: null} +
+ + +
+
+
, + document.body, + ); +} diff --git a/packages/views/board/layout.test.ts b/packages/views/board/layout.test.ts new file mode 100644 index 0000000..8860324 --- /dev/null +++ b/packages/views/board/layout.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { boardColumns, revealColumnCount, visibleColumnCount } from "./layout.ts"; + +test("visibleColumnCount fits fixed-width columns and never drops below one", () => { + assert.equal(visibleColumnCount(0), 1); + assert.equal(visibleColumnCount(319), 1); + assert.equal(visibleColumnCount(320), 1); + assert.equal(visibleColumnCount(648), 2); + assert.equal(visibleColumnCount(1020), 3); +}); + +test("revealColumnCount appends a batch and stops at the end", () => { + assert.equal(revealColumnCount(2, 5, 2), 4); + assert.equal(revealColumnCount(4, 5, 2), 5); + assert.equal(revealColumnCount(5, 5, 2), 5); + assert.equal(revealColumnCount(0, 3, 0), 1); +}); + +test("boardColumns appends the ungrouped lane", () => { + const cols = boardColumns({ + cards: [ + { + work: { + id: "w1", + tenant_id: "t", + user_id: "u", + title: "A", + created_at: "", + updated_at: "", + }, + info: { work_id: "w1", checkout: "", body: "", updated_at: "" }, + dirs: [], + sessions: [], + running: 0, + pending: 0, + }, + ], + ungrouped: [{ engine: "agent", session_id: "s1", summary: "闲", checkout: "", running: false, pending: 0, updated_at: "" }], + }); + assert.equal(cols.length, 2); + assert.equal(cols[1]?.ungrouped, true); + assert.equal(cols[1]?.sessions[0]?.session_id, "s1"); +}); diff --git a/packages/views/board/layout.ts b/packages/views/board/layout.ts new file mode 100644 index 0000000..0a89022 --- /dev/null +++ b/packages/views/board/layout.ts @@ -0,0 +1,45 @@ +import type { BoardView, Card, SessionView } from "@codedock/core/board"; + +export const BOARD_COL_WIDTH = 320; +export const BOARD_COL_GAP = 8; + +export type BoardColumn = { + id: string; + title: string; + card?: Card; + sessions: SessionView[]; + ungrouped: boolean; +}; + +// visibleColumnCount 当前视口横向能放下几列,至少 1。 +export function visibleColumnCount(width: number): number { + if (width <= 0) { + return 1; + } + return Math.max(1, Math.floor((width + BOARD_COL_GAP) / (BOARD_COL_WIDTH + BOARD_COL_GAP))); +} + +// revealColumnCount 滑动到末尾时再露出一批,不超过总数。 +export function revealColumnCount(shown: number, total: number, batch: number): number { + const step = Math.max(1, batch); + return Math.min(Math.max(0, total), Math.max(0, shown) + step); +} + +// boardColumns 把已归组卡和未归组列收成横向列。 +export function boardColumns(view: BoardView): BoardColumn[] { + const cols: BoardColumn[] = (view.cards ?? []).map((card) => ({ + id: card.work.id, + title: card.work.title || "未命名", + card, + sessions: card.sessions ?? [], + ungrouped: false, + })); + cols.push({ + id: "ungrouped", + title: "未分组", + sessions: view.ungrouped ?? [], + ungrouped: true, + }); + return cols; +} + diff --git a/packages/views/board/provider.tsx b/packages/views/board/provider.tsx new file mode 100644 index 0000000..f635c0e --- /dev/null +++ b/packages/views/board/provider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { BoardClient } from "@codedock/core/board"; +import { createContext, useContext, type ReactNode } from "react"; + +type BoardContextValue = { + client: BoardClient; +}; + +const BoardContext = createContext(null); + +// BoardProvider 只注入 BoardClient,不扩 AgentContext。 +export function BoardProvider({ client, children }: { client: BoardClient; children: ReactNode }) { + return {children}; +} + +// useBoard 读取看板客户端。 +export function useBoard(): BoardContextValue { + const ctx = useContext(BoardContext); + if (!ctx) { + throw new Error("useBoard must be used within BoardProvider"); + } + return ctx; +} diff --git a/packages/views/board/session-float.tsx b/packages/views/board/session-float.tsx new file mode 100644 index 0000000..6b551df --- /dev/null +++ b/packages/views/board/session-float.tsx @@ -0,0 +1,494 @@ +"use client"; + +import type { BoardEngine } from "@codedock/core/board"; +import type { TimelineItem } from "@codedock/core/chat"; +import { Button } from "@codedock/ui"; +import { Settings, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; + +import { ApprovalDock } from "../chat/approval-dock.tsx"; +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { ConversationTimeline } from "../chat/conversation-timeline.tsx"; +import { useSessionTimeline } from "../chat/hooks/use-session-timeline.ts"; +import { shortWorkspace } from "../chat/lib/format.ts"; +import { createSessionError } from "../chat/lib/workspace.ts"; +import { PendingDock } from "../chat/pending-dock.tsx"; +import { PromptBar } from "../chat/prompt-bar.tsx"; +import { ClaudePane } from "../claude/claude-pane.tsx"; +import { useClaude } from "../claude/provider.tsx"; +import { CodexPane } from "../codex/codex-pane.tsx"; +import { useCodex } from "../codex/provider.tsx"; +import { useAgent } from "../provider.tsx"; +import { useBoard } from "./provider.tsx"; +import { SessionLinkEditor } from "./session-links.tsx"; + +export type FloatSession = { + id: string; + engine: SessionEngine; +}; + +const MIN_W = 320; +const MIN_H = 240; + +type FloatBox = { x: number; y: number; w: number; h: number }; + +type ResizeEdge = { n?: boolean; s?: boolean; e?: boolean; w?: boolean }; + +type Gesture = { + kind: "move" | "resize"; + edge?: ResizeEdge; + x: number; + y: number; + box: FloatBox; +}; + +// SessionFloat 看板不退时用可拖悬浮窗看完整对话;无遮罩,关掉前始终浮在壳上。 +export function SessionFloat({ + session, + onClose, + onOpenPlan, + onOpenFile, + onOpenGit, + initialLinksOpen = false, + notice = null, +}: { + session: FloatSession; + onClose: () => void; + onOpenPlan: Parameters[0]["onOpenPlan"]; + onOpenFile: Parameters[0]["onOpenFile"]; + onOpenGit: () => void; + /** 刚靠链接建好会话时,设置面板保持打开。 */ + initialLinksOpen?: boolean; + /** 会话已经建好,但没能挂进分组。关掉新建窗后仍留在这里。 */ + notice?: string | null; +}) { + const { pickFiles } = useAgent(); + const timeline = useSessionTimeline(session.engine === "agent" ? session.id : undefined); + const pendingApprovals = useMemo( + () => + timeline.state.items.filter( + (item): item is Extract => + item.kind === "approval" && item.status === "pending", + ), + [timeline.state.items], + ); + const [linksOpen, setLinksOpen] = useState(initialLinksOpen); + const seenSession = useRef(`${session.engine}:${session.id}`); + + useEffect(() => { + const key = `${session.engine}:${session.id}`; + if (seenSession.current === key) { + return; + } + seenSession.current = key; + setLinksOpen(false); + }, [session.id, session.engine]); + + const engineLabel = session.engine === "codex" ? "Codex" : session.engine === "claude" ? "Claude" : "Local"; + return ( + setLinksOpen((open) => !open)} + > + + + } + > + {linksOpen ? : null} +
+ {notice ? ( +
+ {notice} +
+ ) : null} + {session.engine === "codex" ? ( + undefined} + onNewConversation={() => undefined} + onListChange={async () => undefined} + /> + ) : session.engine === "claude" ? ( + undefined} + onNewConversation={() => undefined} + onListChange={async () => undefined} + /> + ) : ( + <> + {timeline.error ? ( +
+ {timeline.error} +
+ ) : null} + +
+ + + +
+ + )} +
+
+ ); +} + +export type ComposeDraft = { + workId: string; + title: string; + engine: SessionEngine; + directory: string; +}; + +// ComposeFloat 在看板上弹出输入窗。关掉不建会话。发出消息,或保存 Issue/PR 链接,才创建并挂到这张卡。 +export function ComposeFloat({ + draft, + onClose, + onSent, +}: { + draft: ComposeDraft; + onClose: () => void; + /** source 为 links 时,会话是靠保存链接建的,设置面板保持打开。notice 是挂分组失败、关掉新建窗后仍要看见的那句。 */ + onSent: (id: string, engine: SessionEngine, source?: "links", notice?: string) => void; +}) { + const { client, userId, pickFiles } = useAgent(); + const { client: codex } = useCodex(); + const { client: claude } = useClaude(); + const { client: board } = useBoard(); + const [sending, setSending] = useState(false); + const [linksOpen, setLinksOpen] = useState(false); + const [error, setError] = useState(null); + const engineLabel = draft.engine === "codex" ? "Codex" : draft.engine === "claude" ? "Claude" : "Local"; + const directory = draft.directory.trim(); + + // attach 把刚建好的会话挂到这张卡。选过目录时在这里记下,之后不能再改。 + const attach = async (id: string) => { + if (directory) { + await board.bindDirectory(draft.engine, id, directory); + } + await board.attachPlacement(draft.workId, draft.engine, id); + }; + + // discard 链接没挂上时清掉刚建的空会话。 + const discard = async (id: string) => { + if (draft.engine === "codex") { + await codex.archiveSession(id); + } else if (draft.engine === "claude") { + await claude.archiveSession(id); + } else { + await client.archiveSession(id); + } + }; + + // createFromLinks 保存非空 Issue/PR 时建会话,不必先发消息。 + const createFromLinks = async (links: string[]) => { + if (links.length === 0) { + return; + } + setError(null); + let createdId = ""; + try { + if (draft.engine === "codex") { + const session = await codex.createSession(directory ? { cwd: directory } : {}); + createdId = session.id; + } else if (draft.engine === "claude") { + const session = await claude.createSession(); + createdId = session.id; + if (directory) { + await claude.applySettings(createdId, { cwd: directory }); + } + } else { + const session = await client.createSession({ + user_id: userId, + workspace_id: directory || undefined, + }); + createdId = session.id; + } + await board.replaceLinks(draft.engine, createdId, links); + let notice: string | undefined; + try { + await attach(createdId); + } catch (err) { + notice = createSessionError(err, "会话已创建,但没能放到分组"); + } + onSent(createdId, draft.engine, "links", notice); + } catch (err) { + if (createdId) { + try { + await discard(createdId); + } catch { + // 链接没挂上时尽量清掉空会话 + } + } + throw err instanceof Error ? err : new Error("无法创建会话"); + } + }; + + return ( + setLinksOpen((open) => !open)} + > + + + } + > + {linksOpen ? ( + + ) : null} +
+
+

发送后放到 {draft.title || "未命名"}

+ {directory ?

{shortWorkspace(directory)}

: null} + {error ?

{error}

: null} +
+ {draft.engine === "codex" ? ( + onSent(id, "codex")} + onCreated={attach} + onNewConversation={onClose} + onListChange={async () => undefined} + /> + ) : draft.engine === "claude" ? ( + onSent(id, "claude")} + onCreated={attach} + onNewConversation={onClose} + onListChange={async () => undefined} + /> + ) : ( +
+ undefined} + onSend={async (text, mode, approval) => { + setError(null); + setSending(true); + let createdId = ""; + try { + const session = await client.createSession({ + user_id: userId, + workspace_id: directory || undefined, + }); + createdId = session.id; + await client.startRun(session.id, { content: text, mode, approval }); + let notice: string | undefined; + try { + await attach(session.id); + } catch (err) { + notice = createSessionError(err, "会话已发出,但没能放到分组"); + } + onSent(session.id, "agent", undefined, notice); + } catch (err) { + if (createdId) { + try { + await client.archiveSession(createdId); + } catch { + // 发出失败时尽量清掉刚建的空会话 + } + } + setError(createSessionError(err, "发送失败")); + } finally { + setSending(false); + } + }} + /> +
+ )} +
+
+ ); +} + +// FloatShell 可拖、可改大小的悬浮窗壳,无遮罩。 +function FloatShell({ + title, + onClose, + actions, + children, +}: { + title: string; + onClose: () => void; + actions?: ReactNode; + children: ReactNode; +}) { + const [box, setBox] = useState({ x: 72, y: 72, w: 560, h: 640 }); + const gesture = useRef(null); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + const current = gesture.current; + if (!current) { + return; + } + const dx = event.clientX - current.x; + const dy = event.clientY - current.y; + if (current.kind === "move") { + setBox(clampBox({ ...current.box, x: current.box.x + dx, y: current.box.y + dy })); + return; + } + setBox(resizeBox(current.box, current.edge ?? {}, dx, dy)); + }; + const onUp = () => { + gesture.current = null; + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, []); + + // beginMove 从标题栏拖动整窗,点到按钮和输入时不拖。 + const beginMove = (event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("button, input, a, textarea, select")) { + return; + } + gesture.current = { kind: "move", x: event.clientX, y: event.clientY, box }; + }; + + // beginResize 从边或角改宽高,西/北侧同时挪位置。 + const beginResize = (edge: ResizeEdge) => (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + gesture.current = { kind: "resize", edge, x: event.clientX, y: event.clientY, box }; + }; + + return ( +
+ + + + + + + + +
+

{title}

+ {actions} + +
+ {children} +
+ ); +} + +// ResizeHandle 是悬浮窗边上的拖拽热区。 +function ResizeHandle({ + label, + className, + onPointerDown, +}: { + label: string; + className: string; + onPointerDown: (event: ReactPointerEvent) => void; +}) { + return ( +
+ ); +} + +// resizeBox 按拖动方向改宽高;西、北侧同时移动原点。 +function resizeBox(start: FloatBox, edge: ResizeEdge, dx: number, dy: number): FloatBox { + let { x, y, w, h } = start; + if (edge.e) { + w = start.w + dx; + } + if (edge.s) { + h = start.h + dy; + } + if (edge.w) { + w = start.w - dx; + x = start.x + dx; + } + if (edge.n) { + h = start.h - dy; + y = start.y + dy; + } + if (w < MIN_W) { + if (edge.w) { + x -= MIN_W - w; + } + w = MIN_W; + } + if (h < MIN_H) { + if (edge.n) { + y -= MIN_H - h; + } + h = MIN_H; + } + return clampBox({ x, y, w, h }); +} + +// clampBox 把窗限制在视口内,并守住最小宽高。 +function clampBox(box: FloatBox): FloatBox { + const maxW = Math.max(MIN_W, window.innerWidth - 16); + const maxH = Math.max(MIN_H, window.innerHeight - 16); + const w = Math.min(Math.max(MIN_W, box.w), maxW); + const h = Math.min(Math.max(MIN_H, box.h), maxH); + const x = Math.min(Math.max(8, box.x), Math.max(8, window.innerWidth - w - 8)); + const y = Math.min(Math.max(8, box.y), Math.max(8, window.innerHeight - h - 8)); + return { x, y, w, h }; +} diff --git a/packages/views/board/session-links.tsx b/packages/views/board/session-links.tsx new file mode 100644 index 0000000..022a7e9 --- /dev/null +++ b/packages/views/board/session-links.tsx @@ -0,0 +1,151 @@ +"use client"; + +import type { BoardEngine, SessionLinks } from "@codedock/core/board"; +import { Button } from "@codedock/ui"; +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useBoard } from "./provider.tsx"; + +// SessionLinkEditor 按行编辑会话上的 GitHub 链接,不让用户选择 Issue 还是 PR。没有会话时先记在本地。 +export function SessionLinkEditor({ + engine, + sessionId, + draft, + onDraft, +}: { + engine?: BoardEngine; + sessionId?: string; + /** 还没有会话时已经记下的链接。 */ + draft?: string[]; + /** 还没有会话时保存。非空链接由调用方建会话;失败时抛错,编辑器留在原地。 */ + onDraft?: (links: string[]) => void | Promise; +}) { + const { client } = useBoard(); + const [rows, setRows] = useState(() => (draft && draft.length > 0 ? draft : [""])); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!engine || !sessionId) { + return; + } + let cancelled = false; + void client + .getLinks(engine, sessionId) + .then((links) => { + if (cancelled) { + return; + } + const next = rowsFromLinks(links); + if (!next.some((row) => row.trim()) && draft?.some((row) => row.trim())) { + setRows(draft); + return; + } + setRows(next); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "无法读取链接"); + } + }); + return () => { + cancelled = true; + }; + }, [client, draft, engine, sessionId]); + + // save 把非空行整批换上去。还没有会话时交给调用方,有链接就建会话。 + const save = async () => { + const links = rows.map((row) => row.trim()).filter(Boolean); + if (!engine || !sessionId) { + setBusy(true); + setError(null); + try { + await onDraft?.(links); + setRows(links.length > 0 ? links : [""]); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + return; + } + setBusy(true); + setError(null); + try { + const saved = await client.replaceLinks(engine, sessionId, links); + setRows(rowsFromLinks(saved)); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + }; + + return ( +
+

加在对话前面。Issue 和 PR 贴 GitHub 链接。

+
+ {rows.map((row, index) => ( +
+ { + const next = rows.slice(); + next[index] = event.target.value; + setRows(next); + }} + /> + +
+ ))} +
+
+ + +
+ {error ?

{error}

: null} +
+ ); +} + +// rowsFromLinks 把已挂快照收成一行一个链接;没有时留一条空行。 +function rowsFromLinks(links: SessionLinks): string[] { + const rows: string[] = []; + if (links.issue) { + rows.push(linkText(links.issue.url, links.issue.repo, links.issue.number, "issues")); + } + for (const pull of links.pulls) { + rows.push(linkText(pull.url, pull.repo, pull.number, "pull")); + } + return rows.length > 0 ? rows : [""]; +} + +// linkText 优先用快照里的地址,没有就按仓库和编号拼一条 GitHub 链接。 +function linkText(url: string, repo: string, number: number, path: "issues" | "pull"): string { + if (url) { + return url; + } + if (repo && number > 0) { + return `https://github.com/${repo}/${path}/${number}`; + } + return ""; +} diff --git a/packages/views/board/work-column.tsx b/packages/views/board/work-column.tsx new file mode 100644 index 0000000..eb64ebd --- /dev/null +++ b/packages/views/board/work-column.tsx @@ -0,0 +1,418 @@ +"use client"; + +import type { BoardEngine, InboxItem, SessionView } from "@codedock/core/board"; +import { Button, MessageResponse } from "@codedock/ui"; +import { useEffect, useRef, useState } from "react"; + +import { useClaude } from "../claude/provider.tsx"; +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { relativeTime, shortWorkspace } from "../chat/lib/format.ts"; +import { useCodex } from "../codex/provider.tsx"; +import { useAgent } from "../provider.tsx"; +import { InboxActions } from "./inbox-actions.tsx"; +import { InfoEditor } from "./info-editor.tsx"; +import type { BoardColumn } from "./layout.ts"; +import { useBoard } from "./provider.tsx"; + +// WorkColumn 一列上头是卡信息,下面挂会话并可直接批。 +export function WorkColumn({ + column, + onOpenSession, + onDraftSession, + onArchived, + onRefresh, +}: { + column: BoardColumn; + onOpenSession: (id: string, engine: SessionEngine) => void; + onDraftSession?: (workId: string, engine: SessionEngine, directory: string, title: string) => void; + onArchived?: (id: string, engine: SessionEngine) => void; + onRefresh: () => Promise | void; +}) { + const { client } = useBoard(); + const { pickDirectory } = useAgent(); + const [title, setTitle] = useState(column.title); + const [info, setInfo] = useState(column.card?.info.body ?? ""); + const [editingInfo, setEditingInfo] = useState(false); + const [engine, setEngine] = useState("agent"); + const [directory, setDirectory] = useState(""); + const [inbox, setInbox] = useState([]); + const [error, setError] = useState(null); + const workId = column.card?.work.id; + const titleFocused = useRef(false); + const skipTitleSave = useRef(false); + + useEffect(() => { + if (!titleFocused.current) { + setTitle(column.title); + } + setInfo(column.card?.info.body ?? ""); + }, [column.card?.info.body, column.title]); + + useEffect(() => { + if (!workId) { + setInbox([]); + return; + } + void client.listInbox(workId).then(setInbox).catch(() => setInbox([])); + }, [client, workId, column.card?.pending, column.sessions]); + + // saveTitle 离开输入框时改列头标题。用输入框里的字,避免回车时状态还没提交。空白改回去;重名时改回去并提示。 + const saveTitle = async (raw: string) => { + titleFocused.current = false; + if (skipTitleSave.current) { + skipTitleSave.current = false; + return; + } + const next = raw.trim(); + if (!workId || next === "" || next === column.title) { + setTitle(column.title); + return; + } + setError(null); + try { + await client.updateWork(workId, next); + await onRefresh(); + } catch (err) { + setTitle(column.title); + setError(workTitleError(err)); + } + }; + + // revertTitle 放弃这次修改,不提交。 + const revertTitle = () => { + skipTitleSave.current = true; + titleFocused.current = false; + setTitle(column.title); + }; + + // saveInfo 从编辑框覆盖写入这张卡的说明。 + const saveInfo = async (next: string) => { + if (!workId) { + return; + } + if (next !== (column.card?.info.body ?? "")) { + await client.putInfo(workId, next); + setInfo(next); + } + setEditingInfo(false); + void onRefresh(); + }; + + // pickCreateDirectory 为接下来要创建的会话选一个目录。 + const pickCreateDirectory = async () => { + if (!pickDirectory) { + return; + } + setError(null); + try { + const path = await pickDirectory({ start: directory || undefined }); + if (path) { + setDirectory(path); + } + } catch (err) { + setError(err instanceof Error ? err.message : "无法选择目录"); + } + }; + + // prepareSession 弹出悬浮输入窗。发出消息才建会话,并挂到这张卡。 + const prepareSession = () => { + if (!workId || !onDraftSession) { + return; + } + onDraftSession(workId, engine, directory, title.trim() || column.title); + }; + + return ( +
+
+ {column.ungrouped ? ( +

未分组

+ ) : ( + setTitle(event.target.value)} + onFocus={() => { + titleFocused.current = true; + }} + onBlur={(event) => void saveTitle(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } else if (event.key === "Escape") { + revertTitle(); + event.currentTarget.blur(); + } + }} + className="h-7 w-full cursor-text rounded border border-border bg-background px-1.5 text-sm font-semibold text-foreground outline-none focus:border-foreground/40" + /> + )} + {column.card ? ( + <> +
+
+ {info.trim() ? ( + + {info} + + ) : ( +

还没有说明

+ )} +
+ +
+ setEditingInfo(false)} onSave={saveInfo} /> +
+ {column.sessions.length} 会话 + 进行中 {executingCount(column.sessions)} + 待审批 {pendingCount(column.sessions)} +
+
+ + {directory ? ( + + ) : null} +
+
+ + + +
+ + ) : ( +

可以先聊天,再放到某个分组。

+ )} + {error ?

{error}

: null} +
+
    + {column.sessions.length === 0 ? ( +
  • 还没有会话
  • + ) : ( + column.sessions.map((session) => { + const sessionInbox = inbox.filter((item) => item.session_id === session.session_id); + const waiting = session.pending > 0; + const executing = session.running && !waiting; + return ( +
  • +
    +
    + +

    + {engineLabel(session.engine)} + {executing ? · 进行中 : ""} + {waiting ? · 待审批 : ""} + {session.updated_at ? ` · ${relativeTime(session.updated_at)}` : ""} +

    +
    + +
    + + { + if (workId) { + setInbox(await client.listInbox(workId)); + } + await onRefresh(); + }} + /> + {column.ungrouped ? ( + + ) : null} +
  • + ); + }) + )} +
+
+ ); +} + +// SessionArchive 按引擎归档这路会话;进行中的 Local 先停掉再归档。 +function SessionArchive({ + session, + onRefresh, + onArchived, +}: { + session: SessionView; + onRefresh: () => Promise | void; + onArchived?: (id: string, engine: SessionEngine) => void; +}) { + const { client } = useAgent(); + const { client: codex } = useCodex(); + const { client: claude } = useClaude(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const label = session.summary || session.session_id; + + // archive 归档后刷新列,并通知外层关掉还开着的悬浮窗。 + const archive = async () => { + setBusy(true); + setError(null); + try { + if (session.engine === "codex") { + await codex.archiveSession(session.session_id); + } else if (session.engine === "claude") { + await claude.archiveSession(session.session_id); + } else { + const current = await client.getSession(session.session_id); + if (current.active_run_id) { + try { + await client.cancelRun(current.active_run_id); + } catch { + // 已结束则继续归档 + } + } + await client.archiveSession(session.session_id); + } + onArchived?.(session.session_id, session.engine); + await onRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "归档失败"); + } finally { + setBusy(false); + } + }; + + return ( +
+ + {error ?

{error}

: null} +
+ ); +} + +// SessionDirectory 只展示创建会话时绑上的目录。之后不能改,也不能解绑。 +function SessionDirectory({ session }: { session: SessionView }) { + return ( +
+ {session.checkout ? ( +

+ {shortWorkspace(session.checkout)} + {session.branch ? ` · ${session.branch}` : ""} + {session.dirty ? " · 有改动" : ""} +

+ ) : ( +

还没有目录

+ )} +
+ ); +} + +// AttachMenu 把未归组会话补挂到一张已有卡。 +function AttachMenu({ + sessionId, + engine, + onRefresh, +}: { + sessionId: string; + engine: SessionEngine; + onRefresh: () => Promise | void; +}) { + const { client } = useBoard(); + const [works, setWorks] = useState<{ id: string; title: string }[]>([]); + useEffect(() => { + void client.listWorks().then((items) => setWorks(items.map((item) => ({ id: item.id, title: item.title })))); + }, [client]); + if (works.length === 0) { + return null; + } + return ( + + ); +} + +// executingCount 只数还在执行的会话。等审批的不记成进行中。 +function executingCount(sessions: { running: boolean; pending: number }[]): number { + return sessions.filter((session) => session.running && session.pending === 0).length; +} + +// pendingCount 把列内待审批条数加总。 +function pendingCount(sessions: { pending: number }[]): number { + return sessions.reduce((sum, session) => sum + session.pending, 0); +} + +// workTitleError 把重名失败说成界面上的短句。 +export function workTitleError(err: unknown): string { + const raw = err instanceof Error ? err.message : "无法保存"; + if (raw.includes("work title already exists")) { + return "已有同名分组"; + } + return raw; +} + +// engineLabel 列内用短名区分引擎。 +function engineLabel(engine: string): string { + if (engine === "codex") { + return "Codex"; + } + if (engine === "claude") { + return "Claude"; + } + return "Local"; +} diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 4ed4642..9a263a3 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,12 +1,19 @@ "use client"; +import type { Placement, Work } from "@codedock/core/board"; import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/core/chat"; import type { ClaudeSession } from "@codedock/core/claude"; import type { Session as CodexSession } from "@codedock/core/codex"; -import { Button } from "@codedock/ui"; -import { PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, PlusIcon } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Button, cn } from "@codedock/ui"; +import { PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, PlusIcon, Settings } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { BoardGrid } from "../board/board-grid.tsx"; +import { workTitleError } from "../board/work-column.tsx"; +import { groupSessionsByWork } from "../board/group.ts"; +import { useBoard } from "../board/provider.tsx"; +import { ComposeFloat, SessionFloat, type ComposeDraft, type FloatSession } from "../board/session-float.tsx"; +import { SessionLinkEditor } from "../board/session-links.tsx"; import { ClaudePane } from "../claude/claude-pane.tsx"; import { useClaudeSessionList } from "../claude/hooks/use-session-list.ts"; import { useClaude } from "../claude/provider.tsx"; @@ -36,29 +43,60 @@ import { SessionSidebar, type SidebarSession } from "./session-sidebar.tsx"; export type SessionEngine = "agent" | "codex" | "claude"; +const BOARD_DOT = 8; +const BOARD_COVER_MS = 480; +const BOARD_REVEAL_MS = 260; + +type BoardCover = { + x: number; + y: number; + /** 盖住视口四角所需的放大倍数。 */ + scale: number; + phase: "grow" | "covered" | "reveal"; + /** 黑点铺满后要去的那一态。 */ + to: "board" | "chat"; +}; + +// boardCoverScale 算出黑点要放大多少倍才能盖住视口四角。 +function boardCoverScale(x: number, y: number) { + const dx = Math.max(x, window.innerWidth - x); + const dy = Math.max(y, window.innerHeight - y); + return (Math.hypot(dx, dy) / (BOARD_DOT / 2)) * 1.08; +} + export type ChatPageProps = { sessionId?: string; engine?: SessionEngine; + boardMode?: boolean; onOpenSession: (id: string, engine?: SessionEngine) => void; onNewConversation: () => void; + onOpenBoard?: () => void; + onLeaveBoard?: () => void; + /** 看板打开 Local 悬浮会话时,把这路 id 交给宿主装 Git;关掉或换成别的引擎时传 undefined。 */ + onGitSession?: (sessionId: string | undefined) => void; brandSrc?: string; codexIconSrc?: string; claudeIconSrc?: string; headerActions?: ReactNode; }; -// ChatPage 组合会话列表、对话和右侧多窗口栏。 +// ChatPage 组两态:会话三栏,看板藏中间对话、右侧仍是 Plan / 文件 / Git。左上角 logo 和切换按钮两边都在,看板时旁边可一键新建分组。来回切换都从按钮长出黑点盖住屏幕,再褪开露出另一态。 export function ChatPage({ sessionId, engine, + boardMode = false, onOpenSession, onNewConversation, + onOpenBoard, + onLeaveBoard, + onGitSession, brandSrc, codexIconSrc, claudeIconSrc, headerActions, }: ChatPageProps) { const { client, pickDirectory, pickFiles } = useAgent(); + const { client: boardClient } = useBoard(); const { client: codexClient } = useCodex(); const { client: claudeClient } = useClaude(); const list = useSessionList(); @@ -78,6 +116,186 @@ export function ChatPage({ const [pickingWorkspace, setPickingWorkspace] = useState(false); const workbench = useWorkbench(); const columns = useColumnLayout(); + const [works, setWorks] = useState([]); + const [placements, setPlacements] = useState([]); + const [float, setFloat] = useState(null); + const [floatNotice, setFloatNotice] = useState(null); + const [floatLinksOpen, setFloatLinksOpen] = useState(false); + const [compose, setCompose] = useState(null); + const [boardRevision, setBoardRevision] = useState(0); + const [creatingGroup, setCreatingGroup] = useState(false); + const [groupError, setGroupError] = useState(null); + const [pendingWorkId, setPendingWorkId] = useState(null); + const [linksOpen, setLinksOpen] = useState(false); + const [draftLinks, setDraftLinks] = useState([]); + const draftLinksRef = useRef(draftLinks); + draftLinksRef.current = draftLinks; + const [cover, setCover] = useState(null); + const coverRef = useRef(null); + const openBoardRef = useRef(onOpenBoard); + const leaveBoardRef = useRef(onLeaveBoard); + const newConversationRef = useRef(onNewConversation); + openBoardRef.current = onOpenBoard; + leaveBoardRef.current = onLeaveBoard; + newConversationRef.current = onNewConversation; + const wasBoard = useRef(boardMode); + + // openBoardFromSidebar 从按钮中心铺开黑点,铺满后再换到另一态。减动效时直接切换。 + const openBoardFromSidebar = (button: HTMLButtonElement) => { + if (cover) { + return; + } + const to = boardMode ? "chat" : "board"; + if (to === "board" && !onOpenBoard) { + return; + } + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) { + if (to === "chat") { + if (onLeaveBoard) { + onLeaveBoard(); + } else { + onNewConversation(); + } + } else { + onOpenBoard?.(); + } + return; + } + const rect = button.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + setCover({ x, y, scale: boardCoverScale(x, y), phase: "grow", to }); + }; + + // 黑点铺满后才换页,盖层先留着,避免切页时露出另一态。 + useEffect(() => { + const node = coverRef.current; + if (!node || cover?.phase !== "grow") { + return; + } + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + const to = cover.to; + setCover((current) => (current?.phase === "grow" ? { ...current, phase: "covered" } : current)); + if (to === "chat") { + if (leaveBoardRef.current) { + leaveBoardRef.current(); + } else { + newConversationRef.current(); + } + } else { + openBoardRef.current?.(); + } + }; + const anim = node.animate( + [ + { transform: "translate(-50%, -50%) scale(1)" }, + { transform: `translate(-50%, -50%) scale(${cover.scale})` }, + ], + { duration: BOARD_COVER_MS, easing: "cubic-bezier(0.7, 0, 0.15, 1)", fill: "forwards" }, + ); + anim.onfinish = finish; + const timer = window.setTimeout(finish, BOARD_COVER_MS + 40); + return () => { + settled = true; + anim.onfinish = null; + window.clearTimeout(timer); + anim.cancel(); + }; + }, [cover?.phase, cover?.scale, cover?.to]); + + // 目标那一态挂上后再褪开黑点。 + useEffect(() => { + if (!cover || cover.phase !== "covered") { + return; + } + const arrived = cover.to === "board" ? boardMode : !boardMode; + if (!arrived) { + return; + } + const frame = requestAnimationFrame(() => { + setCover((current) => (current?.phase === "covered" ? { ...current, phase: "reveal" } : current)); + }); + return () => cancelAnimationFrame(frame); + }, [boardMode, cover]); + + // 黑点透明度走完后卸掉盖层。 + useEffect(() => { + const node = coverRef.current; + if (!node || cover?.phase !== "reveal") { + return; + } + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + setCover(null); + }; + const anim = node.animate( + [{ opacity: 1 }, { opacity: 0 }], + { duration: BOARD_REVEAL_MS, easing: "ease-out", fill: "forwards" }, + ); + anim.onfinish = finish; + const timer = window.setTimeout(finish, BOARD_REVEAL_MS + 40); + return () => { + settled = true; + anim.onfinish = null; + window.clearTimeout(timer); + anim.cancel(); + }; + }, [cover?.phase]); + + // createGroup 不填标题,由服务端取最小的未命名序号,然后重拉看板。 + const createGroup = () => { + setCreatingGroup(true); + setGroupError(null); + void boardClient + .createWork("") + .then(() => boardClient.listWorks()) + .then((nextWorks) => { + setWorks(nextWorks); + setBoardRevision((value) => value + 1); + }) + .catch((err: unknown) => { + setGroupError(workTitleError(err)); + }) + .finally(() => { + setCreatingGroup(false); + }); + }; + + // 看板里会新建、归组、绑目录;回到会话时重拉左侧列表,避免还显示离开前的分组。 + useEffect(() => { + const leaving = wasBoard.current && !boardMode; + wasBoard.current = boardMode; + if (!leaving) { + return; + } + void list.refresh(); + void codexList.refresh(); + void claudeList.refresh(); + void Promise.all([boardClient.listWorks(), boardClient.listPlacements()]) + .then(([nextWorks, nextPlaces]) => { + setWorks(nextWorks); + setPlacements(nextPlaces); + }) + .catch(() => undefined); + }, [boardClient, boardMode, claudeList.refresh, codexList.refresh, list.refresh]); + + // 看板路径上没有会话 id。Local 悬浮窗打开时,把这路 id 交给宿主去装 Git。 + useEffect(() => { + if (!onGitSession || !boardMode) { + return; + } + onGitSession(float?.engine === "agent" ? float.id : undefined); + }, [boardMode, float, onGitSession]); useEffect(() => { workbench.reset(); @@ -92,6 +310,50 @@ export function ChatPage({ () => mergeSessions(list.sessions, codexList.sessions, claudeList.sessions), [claudeList.sessions, codexList.sessions, list.sessions], ); + const sidebarSessions = useMemo(() => { + if (activeEngine !== "agent" || !sessionId) { + return sessions; + } + return sessions.map((session) => { + if (session.engine !== "agent" || session.id !== sessionId) { + return session; + } + if (timeline.loading) { + return session; + } + const waiting = timeline.state.runStatus === "waiting_approval"; + return { + ...session, + running: waiting ? false : timeline.running, + awaiting: waiting, + }; + }); + }, [activeEngine, sessionId, sessions, timeline.loading, timeline.running, timeline.state.runStatus]); + const someoneRunning = sidebarSessions.some((session) => session.running); + useEffect(() => { + if (boardMode || !someoneRunning) { + return; + } + const timer = window.setInterval(() => { + void list.refresh(); + void codexList.refresh(); + void claudeList.refresh(); + }, 3000); + return () => window.clearInterval(timer); + }, [boardMode, claudeList.refresh, codexList.refresh, list.refresh, someoneRunning]); + // 分组和挂载只在进页面时拉一次。会话列表刷新不重打这两条;离开看板和显式归组另有自己的重拉。 + useEffect(() => { + void Promise.all([boardClient.listWorks(), boardClient.listPlacements()]) + .then(([nextWorks, nextPlaces]) => { + setWorks(nextWorks); + setPlacements(nextPlaces); + }) + .catch(() => undefined); + }, [boardClient]); + const groups = useMemo( + () => groupSessionsByWork(sidebarSessions, placements, works), + [placements, sidebarSessions, works], + ); const currentKey = sessionId ? `${activeEngine}:${sessionId}` : undefined; const current = sessions.find((session) => `${session.engine}:${session.id}` === currentKey); @@ -131,6 +393,84 @@ export function ChatPage({ item.kind === "approval" && item.status === "pending", ); + // commitDraftLinks 还没有会话时,保存非空 Issue/PR 就建会话并打开,不必先发消息。 + const commitDraftLinks = async (links: string[]) => { + draftLinksRef.current = links; + setDraftLinks(links); + if (sessionId || links.length === 0) { + return; + } + setComposerError(null); + setStarting(true); + let createdId = ""; + try { + if (activeEngine === "codex") { + const session = await codexClient.createSession(workspaceDraft.trim() ? { cwd: workspaceDraft.trim() } : {}); + createdId = session.id; + } else if (activeEngine === "claude") { + const session = await claudeClient.createSession(); + createdId = session.id; + if (workspaceDraft.trim()) { + await claudeClient.applySettings(createdId, { cwd: workspaceDraft.trim() }); + } + } else { + const session = await list.createSession(workspaceDraft); + createdId = session.id; + writeLastWorkspace(session.workspace_id); + setWorkspaceDraft(session.workspace_id); + } + if (workspaceDraft.trim()) { + await boardClient.bindDirectory(activeEngine, createdId, workspaceDraft.trim()); + } + await boardClient.replaceLinks(activeEngine, createdId, links); + draftLinksRef.current = []; + setDraftLinks([]); + if (activeEngine === "codex") { + await codexList.refresh(); + } else if (activeEngine === "claude") { + await claudeList.refresh(); + } else { + await list.refresh(); + } + try { + await placePending(createdId, activeEngine); + } catch (err) { + setComposerError(createSessionError(err, "会话已创建,但没能放到分组")); + } + onOpenSession(createdId, activeEngine); + } catch (err) { + if (createdId) { + try { + if (activeEngine === "codex") { + await codexClient.archiveSession(createdId); + } else if (activeEngine === "claude") { + await claudeClient.archiveSession(createdId); + } else { + await client.archiveSession(createdId); + } + } catch { + // 链接没挂上时尽量清掉空会话 + } + } + const message = createSessionError(err, "无法创建会话"); + setComposerError(message); + throw new Error(message); + } finally { + setStarting(false); + } + }; + + // attachDraftLinks 把对话开始前记下的 Issue/PR 挂到刚建好的会话上。 + const attachDraftLinks = async (id: string, engine: SessionEngine) => { + const links = draftLinksRef.current.map((row) => row.trim()).filter(Boolean); + if (links.length === 0) { + return; + } + await boardClient.replaceLinks(engine, id, links); + draftLinksRef.current = []; + setDraftLinks([]); + }; + const onSend = async (text: string, mode: WorkMode, approval: ApprovalMode) => { setComposerError(null); if (sessionId) { @@ -140,11 +480,25 @@ export function ChatPage({ } setStarting(true); try { + const chosenDirectory = workspaceDraft.trim(); const session = await list.createSession(workspaceDraft); writeLastWorkspace(session.workspace_id); setWorkspaceDraft(session.workspace_id); + if (chosenDirectory) { + await boardClient.bindDirectory("agent", session.id, chosenDirectory); + } + try { + await attachDraftLinks(session.id, "agent"); + } catch (err) { + setComposerError(createSessionError(err, "Issue 或 PR 没挂上")); + } await client.startRun(session.id, { content: text, mode, approval }); await list.refresh(); + try { + await placePending(session.id, "agent"); + } catch (err) { + setComposerError(createSessionError(err, "会话已发出,但没能放到分组")); + } onOpenSession(session.id, "agent"); } catch (err) { setComposerError(createSessionError(err, "发送失败")); @@ -176,6 +530,17 @@ export function ChatPage({ }); }; + // placePending 把刚发出消息的会话挂到加号选中的 Work 上。没有待挂分组时什么都不做。 + const placePending = async (id: string, engine: SessionEngine) => { + const workId = pendingWorkId; + if (!workId) { + return; + } + await boardClient.attachPlacement(workId, engine, id); + setPendingWorkId(null); + setPlacements(await boardClient.listPlacements()); + }; + // hideSession 按引擎归档或删除,当前打开的那条会回到新建页。 const hideSession = async (session: SidebarSession) => { const hiddenId = session.id; @@ -226,38 +591,178 @@ export function ChatPage({ workbench.createKind(kind, seed); }; + const rightDock = columns.rightOpen ? ( + <> + columns.moveRight(-delta, persist)} + onCollapse={() => columns.setRightOpen(false)} + /> + + + ) : null; + + const boardCover = cover ? ( +
+
+
+ ) : null; + + const sessionSidebar = ( + ({ id: work.id, title: work.title }))} + currentId={currentKey} + busy={list.busy || codexList.busy || claudeList.busy} + error={ + activeEngine === "codex" + ? codexList.error + : activeEngine === "claude" + ? claudeList.error + : list.error + } + hasMore={codexList.hasMore} + onLoadMore={codexList.hasMore ? () => void codexList.loadMore() : undefined} + onCreate={() => { + setPendingWorkId(null); + onNewConversation(); + }} + pendingWorkId={pendingWorkId} + onCreateInWork={(workId) => { + setPendingWorkId(workId); + onNewConversation(); + }} + onOpenBoard={onOpenBoard ? openBoardFromSidebar : undefined} + onCreateGroup={boardMode ? createGroup : undefined} + creatingGroup={creatingGroup} + onAttach={async (session, workId) => { + await boardClient.attachPlacement(workId, session.engine ?? "agent", session.id); + const [nextWorks, nextPlaces] = await Promise.all([ + boardClient.listWorks(), + boardClient.listPlacements(), + ]); + setWorks(nextWorks); + setPlacements(nextPlaces); + }} + onSelect={(id, nextEngine) => { + setPendingWorkId(null); + onOpenSession(id, nextEngine ?? "agent"); + }} + onRecover={async (runId) => { + await timeline.recover(runId); + await list.refresh(); + }} + canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} + onArchive={async (session) => { + await hideSession(session); + }} + brandSrc={brandSrc} + codexIconSrc={codexIconSrc} + claudeIconSrc={claudeIconSrc} + /> + ); + + if (boardMode) { + return ( +
+
+
+ {sessionSidebar} +
+ {headerActions} + + {columns.rightOpen ? : } + +
+
+ { + setCompose(null); + setFloatNotice(null); + setFloatLinksOpen(false); + setFloat({ id, engine: nextEngine }); + }} + onDraftSession={(workId, nextEngine, directory, title) => { + setFloat(null); + setCompose({ workId, engine: nextEngine, directory, title }); + }} + onArchived={(id, nextEngine) => { + if (float?.id === id && float.engine === nextEngine) { + setFloat(null); + setFloatNotice(null); + } + }} + /> +
+
{rightDock}
+ {boardCover} + {compose ? ( + setCompose(null)} + onSent={(id, nextEngine, source, notice) => { + setCompose(null); + setFloatNotice(notice ?? null); + setFloatLinksOpen(source === "links"); + setFloat({ id, engine: nextEngine }); + setBoardRevision((value) => value + 1); + void boardClient.listPlacements().then(setPlacements).catch(() => undefined); + }} + /> + ) : float ? ( + { + setFloat(null); + setFloatNotice(null); + }} + onOpenPlan={openPlan} + onOpenFile={openFile} + onOpenGit={openGit} + /> + ) : null} +
+ ); + } + return (
{columns.leftOpen ? ( <> - void codexList.loadMore() : undefined} - onCreate={onNewConversation} - onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} - onRecover={async (runId) => { - await timeline.recover(runId); - await list.refresh(); - }} - canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} - onArchive={async (session) => { - await hideSession(session); - }} - brandSrc={brandSrc} - codexIconSrc={codexIconSrc} - claudeIconSrc={claudeIconSrc} - /> + {sessionSidebar} : } {!columns.leftOpen ? ( - @@ -317,6 +829,17 @@ export function ChatPage({ ) : null}
{headerActions} +
+ {linksOpen ? ( + + ) : null} {sessionId ? ( activeEngine === "codex" ? ( <> @@ -419,6 +950,7 @@ export function ChatPage({ setWorkspaceDraft(""); clearLastWorkspace(); }} + placedIn={works.find((work) => work.id === pendingWorkId)?.title} > {draftEngine === "codex" ? ( onOpenSession(id, "codex")} + onPrepare={(id) => attachDraftLinks(id, "codex")} + onCreated={async (id) => { + try { + if (workspaceDraft.trim()) { + await boardClient.bindDirectory("codex", id, workspaceDraft.trim()); + } + await placePending(id, "codex"); + } catch (err) { + setComposerError(createSessionError(err, "会话已发出,但没能放到分组")); + } + }} onNewConversation={onNewConversation} onListChange={codexList.refresh} /> @@ -435,6 +978,17 @@ export function ChatPage({ workspace={workspaceDraft} pickFiles={pickFiles} onOpenSession={(id) => onOpenSession(id, "claude")} + onPrepare={(id) => attachDraftLinks(id, "claude")} + onCreated={async (id) => { + try { + if (workspaceDraft.trim()) { + await boardClient.bindDirectory("claude", id, workspaceDraft.trim()); + } + await placePending(id, "claude"); + } catch (err) { + setComposerError(createSessionError(err, "会话已发出,但没能放到分组")); + } + }} onNewConversation={onNewConversation} onListChange={claudeList.refresh} /> @@ -451,25 +1005,8 @@ export function ChatPage({ )} - {columns.rightOpen ? ( - <> - columns.moveRight(-delta, persist)} - onCollapse={() => columns.setRightOpen(false)} - /> - - - ) : null} +
{rightDock}
+ {boardCover}
); } @@ -498,7 +1035,11 @@ function mergeSessions( claude: ClaudeSession[], ): SidebarSession[] { const mapped: SidebarSession[] = [ - ...agent.map((session) => ({ ...session, engine: "agent" as const })), + ...agent.map((session) => ({ + ...session, + engine: "agent" as const, + ...agentSidebarLive(session), + })), ...codex.map(asCodexSidebarSession), ...claude.map(asClaudeSidebarSession), ]; @@ -530,6 +1071,7 @@ function asCodexSidebarSession(session: CodexSession): SidebarSession { created_at: stampToIso(session.created_at), updated_at: stampToIso(session.updated_at), engine: "codex", + ...turnSidebarLive(Boolean(session.active_turn_id), session.running), }; } @@ -548,9 +1090,27 @@ function asClaudeSidebarSession(session: ClaudeSession): SidebarSession { created_at: stampToIso(session.created_at), updated_at: stampToIso(session.updated_at), engine: "claude", + ...turnSidebarLive(Boolean(session.active_turn_id), session.running), }; } +// agentSidebarLive 只把还在执行的 Local 会话标成进行中。等审批用 awaiting,不扫光。 +function agentSidebarLive(session: Session): { running: boolean; awaiting: boolean } { + const hasRun = Boolean(session.active_run_id) && !session.needs_recover; + if (typeof session.executing === "boolean") { + return { running: session.executing, awaiting: hasRun && !session.executing }; + } + return { running: hasRun, awaiting: false }; +} + +// turnSidebarLive 用列表带来的 running。有回合但 running 为 false 时是待审批。 +function turnSidebarLive(active: boolean, running: boolean | undefined): { running: boolean; awaiting: boolean } { + if (typeof running === "boolean") { + return { running, awaiting: active && !running }; + } + return { running: active, awaiting: false }; +} + // stampToIso 把秒或毫秒时间戳收成 ISO 字符串,无效值用纪元。 function stampToIso(value?: number): string { if (!value) { diff --git a/packages/views/chat/hooks/use-session-list.ts b/packages/views/chat/hooks/use-session-list.ts index 45907c3..b532945 100644 --- a/packages/views/chat/hooks/use-session-list.ts +++ b/packages/views/chat/hooks/use-session-list.ts @@ -18,11 +18,7 @@ export function useSessionList() { let page = 1; while (true) { const listed = await client.listSessions(page, 50); - visible.push( - ...listed.sessions.filter( - (session) => session.status !== "archived" && session.last_event_seq > 0, - ), - ); + visible.push(...listed.sessions.filter((session) => session.status !== "archived")); const pageSize = listed.page.page_size || listed.sessions.length || 50; if (listed.sessions.length === 0 || page * pageSize >= listed.page.total) { break; diff --git a/packages/views/chat/lib/format.ts b/packages/views/chat/lib/format.ts index 921b86e..f3f4eed 100644 --- a/packages/views/chat/lib/format.ts +++ b/packages/views/chat/lib/format.ts @@ -26,6 +26,34 @@ export function relativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } +// compactAge 把时间收成 12m、20h、3d,给侧栏右侧用。 +export function compactAge(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) { + return ""; + } + const minutes = Math.floor(Math.max(0, Date.now() - then) / 60_000); + if (minutes < 1) { + return "now"; + } + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + const days = Math.floor(hours / 24); + if (days < 14) { + return `${days}d`; + } + const weeks = Math.floor(days / 7); + if (weeks < 8) { + return `${weeks}w`; + } + return `${Math.floor(days / 30)}mo`; +} + // sessionTitle 侧栏标题:正文中间可省略,末尾 (n) 必须留下。 export function sessionTitle(id: string, preview?: string): string { const { stem, suffix } = sessionTitleParts(id, preview); diff --git a/packages/views/chat/new-conversation.tsx b/packages/views/chat/new-conversation.tsx index 6669923..eed434e 100644 --- a/packages/views/chat/new-conversation.tsx +++ b/packages/views/chat/new-conversation.tsx @@ -20,6 +20,7 @@ export function NewConversation({ onPick, onClear, canClear, + placedIn, children, }: { brandSrc?: string; @@ -34,6 +35,8 @@ export function NewConversation({ onPick: () => void; onClear: () => void; canClear: boolean; + /** 发出第一条消息后,新会话会挂到这个 Work。 */ + placedIn?: string; children: ReactNode; }) { return ( @@ -72,6 +75,9 @@ export function NewConversation({
+ {placedIn ? ( +

发送后放到 {placedIn}

+ ) : null}
+ {onOpenBoard ? ( + + ) : null} + {boardOpen && onCreateGroup ? ( + + ) : null} + {boardOpen ? null : ( + + )}
- {error ?

{error}

: null} - - + {ungrouped && onAttach && works.length > 0 ? ( + + ) : null} + {onArchive ? ( + + ) : null} + + {compactAge(session.updated_at)} + +
+ ); } -// SidebarSessionTitle 正文可裁,末尾 (n) 不参与省略。 -function SidebarSessionTitle({ id, summary }: { id: string; summary?: string }) { +// SidebarSessionTitle 正文可裁,末尾 (n) 不参与省略。进行中两段各自扫光,待审批改成琥珀呼吸。 +function SidebarSessionTitle({ + id, + summary, + running, + awaiting, +}: { + id: string; + summary?: string; + running?: boolean; + awaiting?: boolean; +}) { const { stem, suffix } = sessionTitleParts(id, summary); + const motion = awaiting ? "live-status-waiting" : running ? "live-status-active" : undefined; return ( - - {stem} - {suffix ? {suffix} : null} + + {stem} + {suffix ? {suffix} : null} ); } @@ -180,7 +425,7 @@ function SessionEngineMark({ Codex ); } @@ -192,7 +437,7 @@ function SessionEngineMark({ Claude ); } @@ -200,8 +445,8 @@ function SessionEngineMark({ return null; } return ( - - Local + + Local ); } diff --git a/packages/views/claude/claude-pane.tsx b/packages/views/claude/claude-pane.tsx index d1a2927..b3fd701 100644 --- a/packages/views/claude/claude-pane.tsx +++ b/packages/views/claude/claude-pane.tsx @@ -19,6 +19,8 @@ export function ClaudePane({ workspace, pickFiles, onOpenSession, + onCreated, + onPrepare, onNewConversation, onListChange, composeOnly = false, @@ -27,6 +29,10 @@ export function ClaudePane({ workspace?: string; pickFiles?: (options?: PickFilesOptions) => Promise; onOpenSession: (id: string) => void; + /** 第一条消息已经发出、会话刚建好时调用,用来挂到 Work。 */ + onCreated?: (id: string) => Promise; + /** 会话刚建好、第一条消息发出前调用,用来先挂上 Issue/PR。 */ + onPrepare?: (id: string) => Promise; onNewConversation: () => void; onListChange?: () => Promise; composeOnly?: boolean; @@ -73,6 +79,7 @@ export function ClaudePane({ if (patch.model || patch.effort || patch.permission_mode || patch.cwd) { await client.applySettings(created.id, patch); } + await onPrepare?.(created.id); return created.id; }; @@ -99,6 +106,7 @@ export function ClaudePane({ try { const id = await createWithSettings(); await client.startTurn(id, { content: text, mode }); + await onCreated?.(id); await openCreated(id); } catch (err) { fail(err, "无法开对话"); diff --git a/packages/views/codex/codex-pane.tsx b/packages/views/codex/codex-pane.tsx index 6ec8729..26ad682 100644 --- a/packages/views/codex/codex-pane.tsx +++ b/packages/views/codex/codex-pane.tsx @@ -13,11 +13,14 @@ import { useCodex } from "./provider.tsx"; import { CodexPromptBar } from "./prompt-bar.tsx"; import { CodexTimeline } from "./timeline.tsx"; +// CodexPane 组合 Codex 实录和底栏;composeOnly 时只渲染新建页输入,发出消息才建会话。 export function CodexPane({ sessionId, workspace, pickFiles, onOpenSession, + onCreated, + onPrepare, onNewConversation, onListChange, composeOnly = false, @@ -26,6 +29,10 @@ export function CodexPane({ workspace?: string; pickFiles?: (options?: PickFilesOptions) => Promise; onOpenSession: (id: string) => void; + /** 第一条消息已经发出、会话刚建好时调用,用来挂到 Work。 */ + onCreated?: (id: string) => Promise; + /** 会话刚建好、第一条消息发出前调用,用来先挂上 Issue/PR。 */ + onPrepare?: (id: string) => Promise; onNewConversation: () => void; onListChange?: () => Promise; composeOnly?: boolean; @@ -61,6 +68,7 @@ export function CodexPane({ ...pendingSettings, cwd: workspace?.trim() || pendingSettings.cwd, }); + await onPrepare?.(created.id); return created.id; }; @@ -87,6 +95,7 @@ export function CodexPane({ try { const id = await createWithSettings(); await client.startTurn(id, { content: text, mode }); + await onCreated?.(id); await openCreated(id); } catch (err) { fail(err, "无法开对话"); diff --git a/packages/views/index.ts b/packages/views/index.ts index fa86e93..29bee73 100644 --- a/packages/views/index.ts +++ b/packages/views/index.ts @@ -8,6 +8,15 @@ export { export { GitPage, GitProvider, useGit, type GitPageProps } from "./git/index.ts"; export { CodexPane, CodexProvider, useCodex } from "./codex/index.ts"; export { ClaudePane, ClaudeProvider, useClaude } from "./claude/index.ts"; +export { + BoardGrid, + BoardProvider, + SessionFloat, + groupSessionsByWork, + useBoard, + type FloatSession, + type WorkGroup, +} from "./board/index.ts"; export { ChatPage, ConversationTimeline, diff --git a/packages/views/package.json b/packages/views/package.json index 6e9aa7d..9613648 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -8,12 +8,17 @@ "./chat": "./chat/index.ts", "./git": "./git/index.ts", "./codex": "./codex/index.ts", - "./claude": "./claude/index.ts" + "./claude": "./claude/index.ts", + "./board": "./board/index.ts" + }, + "scripts": { + "test": "node --test --experimental-strip-types board/group.test.ts board/layout.test.ts chat/column-layout.test.ts chat/lib/composer.test.ts chat/lib/files-window.test.ts chat/lib/terminal.test.ts git/lib/tree.test.ts" }, "dependencies": { "@codedock/core": "workspace:*", "@codedock/ui": "workspace:*", "@git-diff-view/react": "^0.1.7", + "@uiw/react-md-editor": "^4.1.2", "lucide-react": "^0.544.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b26ad7..09d0a29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: '@git-diff-view/react': specifier: ^0.1.7 version: 0.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@uiw/react-md-editor': + specifier: ^4.1.2 + version: 4.1.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.8) @@ -238,6 +241,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -993,6 +1000,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.5': resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: @@ -1069,6 +1079,21 @@ packages: resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/copy-to-clipboard@1.0.21': + resolution: {integrity: sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw==} + + '@uiw/react-markdown-preview@5.2.1': + resolution: {integrity: sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@uiw/react-md-editor@4.1.2': + resolution: {integrity: sha512-EA19S+GS1KDi9MvKoCjJ16luGBE2yZLaBp+6tkNJvIYQ+J6zxdkTAHWl0i+Te02HwQrVMIjVG74M54uyW8K7wQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} @@ -1313,6 +1338,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -1424,6 +1455,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1646,6 +1680,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1967,6 +2005,9 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2036,6 +2077,12 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} @@ -2048,6 +2095,9 @@ packages: hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -2057,6 +2107,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -2772,6 +2825,9 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2840,6 +2896,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2925,6 +2984,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -2938,6 +3003,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + refractor@5.0.0: + resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2951,18 +3019,48 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-attr@4.0.2: + resolution: {integrity: sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow==} + engines: {node: '>=16'} + + rehype-autolink-headings@7.1.0: + resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + rehype-harden@1.1.8: resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + rehype-ignore@2.0.3: + resolution: {integrity: sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==} + engines: {node: '>=16'} + rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-prism-plus@2.0.2: + resolution: {integrity: sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-rewrite@4.0.4: + resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==} + engines: {node: '>=16.0.0'} + rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + remark-cjk-friendly-gfm-strikethrough@2.3.1: resolution: {integrity: sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w==} engines: {node: '>=18'} @@ -2986,6 +3084,10 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-github-blockquote-alert@1.3.1: + resolution: {integrity: sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg==} + engines: {node: '>=16'} + remark-math@6.0.0: resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} @@ -3322,6 +3424,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-filter@5.0.1: + resolution: {integrity: sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -3340,6 +3445,9 @@ packages: unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} @@ -3611,6 +3719,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4297,6 +4407,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -4403,6 +4515,41 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 + '@uiw/copy-to-clipboard@1.0.21': {} + + '@uiw/react-markdown-preview@5.2.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@uiw/copy-to-clipboard': 1.0.21 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-markdown: 10.1.0(@types/react@19.2.18)(react@19.2.8) + rehype-attr: 4.0.2 + rehype-autolink-headings: 7.1.0 + rehype-ignore: 2.0.3 + rehype-prism-plus: 2.0.2 + rehype-raw: 7.0.0 + rehype-rewrite: 4.0.4 + rehype-slug: 6.0.0 + remark-gfm: 4.0.1(supports-color@8.1.1) + remark-github-blockquote-alert: 1.3.1 + unist-util-visit: 5.1.0 + transitivePeerDependencies: + - '@types/react' + - supports-color + + '@uiw/react-md-editor@4.1.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@uiw/react-markdown-preview': 5.2.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + rehype: 13.0.2 + rehype-prism-plus: 2.0.2 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@ungap/structured-clone@1.4.0': {} '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -4609,6 +4756,10 @@ snapshots: baseline-browser-mapping@2.11.20: {} + bcp-47-match@2.0.3: {} + + boolbase@1.0.0: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -4721,6 +4872,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-selector-parser@3.3.0: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.2): @@ -4971,6 +5124,8 @@ snapshots: dependencies: dequal: 2.0.3 + direction@2.0.1: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5444,6 +5599,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5522,6 +5679,14 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.5 @@ -5552,6 +5717,24 @@ snapshots: '@ungap/structured-clone': 1.4.0 unist-util-position: 5.0.0 + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -5596,6 +5779,10 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.5 @@ -6503,6 +6690,10 @@ snapshots: node-releases@2.0.54: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6595,6 +6786,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-numeric-range@1.3.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -6669,6 +6862,24 @@ snapshots: react-is@16.13.1: {} + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.2.18 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6(supports-color@8.1.1) + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.8 + remark-parse: 11.0.0(supports-color@8.1.1) + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react@19.2.8: {} reactivity-store@0.4.0(react@19.2.8): @@ -6689,6 +6900,13 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + refractor@5.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/prismjs': 1.26.6 + hastscript: 9.0.1 + parse-entities: 4.0.2 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -6708,10 +6926,30 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-attr@4.0.2: + dependencies: + unified: 11.0.5 + unist-util-visit: 5.0.0 + + rehype-autolink-headings@7.1.0: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.4.0 + hast-util-heading-rank: 3.0.0 + hast-util-is-element: 3.0.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + rehype-harden@1.1.8: dependencies: unist-util-visit: 5.1.0 + rehype-ignore@2.0.3: + dependencies: + hast-util-select: 6.0.4 + unified: 11.0.5 + unist-util-visit: 5.1.0 + rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.5 @@ -6722,17 +6960,59 @@ snapshots: unist-util-visit-parents: 6.0.2 vfile: 6.0.3 + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-prism-plus@2.0.2: + dependencies: + hast-util-to-string: 3.0.1 + parse-numeric-range: 1.3.0 + refractor: 5.0.0 + rehype-parse: 9.0.1 + unist-util-filter: 5.0.1 + unist-util-visit: 5.1.0 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-rewrite@4.0.4: + dependencies: + hast-util-select: 6.0.4 + unified: 11.0.5 + unist-util-visit: 5.1.0 + rehype-sanitize@6.0.0: dependencies: '@types/hast': 3.0.5 hast-util-sanitize: 5.0.2 + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.5 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.1 + unist-util-visit: 5.1.0 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.5 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + remark-cjk-friendly-gfm-strikethrough@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@8.1.1))(supports-color@8.1.1)(unified@11.0.5): dependencies: mdast-util-to-markdown-cjk-friendly-gfm-strikethrough: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(supports-color@8.1.1) @@ -6767,6 +7047,10 @@ snapshots: transitivePeerDependencies: - supports-color + remark-github-blockquote-alert@1.3.1: + dependencies: + unist-util-visit: 5.1.0 + remark-math@6.0.0(supports-color@8.1.1): dependencies: '@types/mdast': 4.0.4 @@ -7235,6 +7519,12 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-filter@5.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -7262,6 +7552,12 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.1 + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unist-util-visit@5.1.0: dependencies: '@types/unist': 3.0.3 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d4c8610..d5bf5a8 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "codedock/internal/agent" agenttools "codedock/internal/agent/tools" + boardpkg "codedock/internal/board" intcodex "codedock/internal/codex" "codedock/internal/config" "codedock/internal/events" @@ -99,7 +100,22 @@ func main() { api := handler.New(client, queries, runtime, bus, defaults, cfg, logger.NewLogger("handler")) codexRT := intcodex.New(intcodex.Options{Bin: cfg.CodexBin}) defer func() { _ = codexRT.Close() }() + api.SetCodex(codexRT) codexAPI := codexhttp.New(codexRT) + codexAPI.SetPacket(func(ctx context.Context, sessionID string) string { + pkt, err := boardpkg.BuildPacket(ctx, queries, boardpkg.EngineCodex, sessionID) + if err != nil { + return "" + } + return pkt.Text + }) + codexAPI.SetDirectory(func(ctx context.Context, sessionID string) string { + path, err := boardpkg.GetSessionDirectory(ctx, queries, boardpkg.EngineCodex, sessionID) + if err != nil { + return "" + } + return path + }) server := &http.Server{ Addr: cfg.HTTPAddr, diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 7cb7927..18c7c6f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" ) -// newRouter 注册健康检查、Agent CRUD、独立 /codex 与 /claude 路由。 +// newRouter 注册健康检查、Agent CRUD、看板 /works /board、独立 /codex 与 /claude 路由。 func newRouter(log *slog.Logger, api *handler.API, codexAPI *codexhttp.API) http.Handler { router := chi.NewRouter() router.Use(cors) @@ -54,6 +54,44 @@ func newRouter(log *slog.Logger, api *handler.API, codexAPI *codexhttp.API) http r.Get("/{scope}/{scope_id}", api.GetTextMemory) r.Delete("/{scope}/{scope_id}", api.DeleteTextMemory) }) + router.Route("/works", func(r chi.Router) { + r.Post("/", api.CreateWork) + r.Get("/", api.ListWorks) + r.Get("/{work_id}", api.GetWork) + r.Patch("/{work_id}", api.UpdateWork) + r.Delete("/{work_id}", api.DeleteWork) + r.Post("/{work_id}/checkouts", api.AttachWorkCheckout) + r.Get("/{work_id}/checkouts", api.ListWorkCheckouts) + r.Delete("/{work_id}/checkouts", api.DetachWorkCheckout) + r.Get("/{work_id}/info", api.GetWorkInfo) + r.Put("/{work_id}/info", api.PutWorkInfo) + r.Post("/{work_id}/sessions", api.StartWorkSession) + r.Post("/{work_id}/placements", api.AttachWorkPlacement) + r.Get("/{work_id}/inbox", api.ListWorkInbox) + }) + router.Route("/board", func(r chi.Router) { + r.Get("/", api.GetBoard) + r.Get("/cards/{work_id}", api.GetBoardCard) + }) + router.Get("/placements", api.ListPlacements) + router.Route("/placements/{engine}/{session_id}", func(r chi.Router) { + r.Get("/", api.GetPlacement) + r.Delete("/", api.DeletePlacement) + r.Get("/packet", api.GetPlacementPacket) + }) + router.Post("/inbox/decision", api.DecideInbox) + router.Route("/session-directories/{engine}/{session_id}", func(r chi.Router) { + r.Put("/", api.BindSessionDirectory) + r.Delete("/", api.BindSessionDirectory) + }) + router.Route("/session-links/{engine}/{session_id}", func(r chi.Router) { + r.Get("/", api.GetSessionLinks) + r.Put("/", api.ReplaceSessionLinks) + r.Put("/issue", api.PutSessionIssue) + r.Delete("/issue", api.DeleteSessionIssue) + r.Post("/pulls", api.PutSessionPull) + r.Delete("/pulls/{number}", api.DeleteSessionPull) + }) router.Route("/git", func(r chi.Router) { r.Get("/status", api.GitStatus) r.Get("/diff", api.GitDiff) diff --git a/server/internal/agent/coordinator.go b/server/internal/agent/coordinator.go index 2006648..1827646 100644 --- a/server/internal/agent/coordinator.go +++ b/server/internal/agent/coordinator.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "codedock/internal/board" cderr "codedock/internal/errors" "codedock/internal/events" "codedock/internal/util" @@ -310,6 +311,14 @@ func (r *Runtime) LoadAgentState(ctx context.Context, runID string) (pkgagent.Ag return pkgagent.AgentState{}, pkgagent.History{}, err } + workID := "" + packet := "" + if place, placeErr := board.GetPlacement(ctx, q, board.EngineNative, sess.ID); placeErr == nil { + workID = place.WorkID + if pkt, pktErr := board.BuildPacket(ctx, q, board.EngineNative, sess.ID); pktErr == nil { + packet = pkt.Text + } + } hist := pkgagent.History{ Run: run, Turn: nextTurn, @@ -318,7 +327,8 @@ func (r *Runtime) LoadAgentState(ctx context.Context, runID string) (pkgagent.Ag Tools: tools, Prompt: prompt, Hidden: hidden, - MemoryIndexes: r.loadMemoryIndexes(ctx, sess.UserID, sess.WorkspaceID), + MemoryIndexes: r.loadMemoryIndexes(ctx, sess.UserID, workID), + Packet: packet, } return state, hist, nil } diff --git a/server/internal/agent/coordinator_test.go b/server/internal/agent/coordinator_test.go index c8f7f94..c3c7443 100644 --- a/server/internal/agent/coordinator_test.go +++ b/server/internal/agent/coordinator_test.go @@ -473,8 +473,8 @@ func TestLoadMemoryIndexesAndCompact(t *testing.T) { if err != nil { t.Fatal(err) } - if len(hist.MemoryIndexes) != 2 { - t.Fatalf("indexes=%d", len(hist.MemoryIndexes)) + if len(hist.MemoryIndexes) != 1 { + t.Fatalf("indexes=%d want user only, got %v", len(hist.MemoryIndexes), hist.MemoryIndexes) } over := strings.Repeat("line\n", memory.IndexMaxLines+2) diff --git a/server/internal/agent/memory/memory_test.go b/server/internal/agent/memory/memory_test.go index c326185..d0c7196 100644 --- a/server/internal/agent/memory/memory_test.go +++ b/server/internal/agent/memory/memory_test.go @@ -110,6 +110,19 @@ func TestUpsertIndexOverBudgetStillWrites(t *testing.T) { } } +// TestWorkScopeRoundTrip 确认 work 范围记忆可写入并按 scope_id 列出。 +func TestWorkScopeRoundTrip(t *testing.T) { + q, ctx := testQueries(t) + item, err := Upsert(ctx, q, TextMemory{Scope: ScopeWork, ScopeID: "w1", Name: NameIndex, Content: "work notes"}) + if err != nil || item.Scope != ScopeWork || item.ScopeID != "w1" { + t.Fatalf("upsert %+v %v", item, err) + } + listed, err := List(ctx, q, ScopeWork, "w1") + if err != nil || len(listed) != 1 || listed[0].Content != "work notes" { + t.Fatalf("list %+v %v", listed, err) + } +} + func TestIndexAndSearchMessages(t *testing.T) { q, ctx := testQueries(t) if err := IndexMessage(ctx, q, ContextMessage{ diff --git a/server/internal/agent/memory/text.go b/server/internal/agent/memory/text.go index 0465f44..b14720c 100644 --- a/server/internal/agent/memory/text.go +++ b/server/internal/agent/memory/text.go @@ -134,7 +134,7 @@ func normalizeKey(key TextMemoryKey) (TextMemoryKey, error) { key.Kind = KindFromName(key.Name) } switch key.Scope { - case ScopeUser, ScopeWorkspace: + case ScopeUser, ScopeWorkspace, ScopeWork: default: return key, cderr.Invalid("invalid memory scope") } diff --git a/server/internal/agent/memory/types.go b/server/internal/agent/memory/types.go index 7f3c8be..35a2c42 100644 --- a/server/internal/agent/memory/types.go +++ b/server/internal/agent/memory/types.go @@ -11,7 +11,8 @@ type TextMemoryScope string const ( ScopeUser TextMemoryScope = "user" // ScopeID 为 user_id。 - ScopeWorkspace TextMemoryScope = "workspace" // ScopeID 为 workspace_id。 + ScopeWorkspace TextMemoryScope = "workspace" // ScopeID 为 workspace_id;热层保留,本回合不装。 + ScopeWork TextMemoryScope = "work" // ScopeID 为 work_id。 ) // TextMemoryKind 区分目录与专题。 diff --git a/server/internal/agent/memory_index.go b/server/internal/agent/memory_index.go index 3c15457..92617fa 100644 --- a/server/internal/agent/memory_index.go +++ b/server/internal/agent/memory_index.go @@ -76,8 +76,8 @@ func (r *Runtime) compactIndex(ctx context.Context, key memory.TextMemoryKey) { } } -// loadMemoryIndexes 读取用户与工作区冻结目录,供本 Session 装上下文。 -func (r *Runtime) loadMemoryIndexes(ctx context.Context, userID, workspaceID string) []string { +// loadMemoryIndexes 读取用户与当前 Work 冻结目录;工作区热层保留但不进本回合。 +func (r *Runtime) loadMemoryIndexes(ctx context.Context, userID, workID string) []string { if r == nil { return nil } @@ -93,10 +93,10 @@ func (r *Runtime) loadMemoryIndexes(ctx context.Context, userID, workspaceID str out = append(out, item.Content) } } - if workspaceID != "" { + if workID != "" { if item, err := memory.Get(ctx, q, memory.TextMemoryKey{ - Scope: memory.ScopeWorkspace, - ScopeID: workspaceID, + Scope: memory.ScopeWork, + ScopeID: workID, Kind: memory.KindIndex, Name: memory.NameIndex, }); err == nil && item.Content != "" { diff --git a/server/internal/agent/memory_index_test.go b/server/internal/agent/memory_index_test.go new file mode 100644 index 0000000..746e8e0 --- /dev/null +++ b/server/internal/agent/memory_index_test.go @@ -0,0 +1,36 @@ +package agent + +import ( + "context" + "testing" + + "codedock/internal/agent/memory" + "codedock/pkg/db" +) + +func TestLoadMemoryIndexesUsesUserAndWork(t *testing.T) { + ctx := context.Background() + client, err := db.Open(ctx, db.Config{Engine: db.EngineSQLite, DSN: "file:memindex?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := db.Migrate(ctx, client.DB()); err != nil { + t.Fatal(err) + } + q := db.SQLiteQueries(client) + if _, err := memory.Upsert(ctx, q, memory.TextMemory{Scope: memory.ScopeUser, ScopeID: "u1", Name: memory.NameIndex, Content: "user-dir"}); err != nil { + t.Fatal(err) + } + if _, err := memory.Upsert(ctx, q, memory.TextMemory{Scope: memory.ScopeWorkspace, ScopeID: "ws1", Name: memory.NameIndex, Content: "ws-dir"}); err != nil { + t.Fatal(err) + } + if _, err := memory.Upsert(ctx, q, memory.TextMemory{Scope: memory.ScopeWork, ScopeID: "work-1", Name: memory.NameIndex, Content: "work-dir"}); err != nil { + t.Fatal(err) + } + rt := &Runtime{queries: q} + got := rt.loadMemoryIndexes(ctx, "u1", "work-1") + if len(got) != 2 || got[0] != "user-dir" || got[1] != "work-dir" { + t.Fatalf("indexes=%v", got) + } +} diff --git a/server/internal/agent/tools/memory.go b/server/internal/agent/tools/memory.go index 3b2fe4e..8ca3170 100644 --- a/server/internal/agent/tools/memory.go +++ b/server/internal/agent/tools/memory.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "strings" "codedock/internal/agent/memory" cderr "codedock/internal/errors" @@ -252,6 +253,15 @@ func scopeIDFromSession(ctx context.Context, q *sqlite.Queries, sessionID string return "default", nil } return session.WorkspaceID, nil + case memory.ScopeWork: + place, err := q.GetSessionPlacement(ctx, sqlite.GetSessionPlacementParams{ + Engine: "native", + SessionID: sessionID, + }) + if err != nil || strings.TrimSpace(place.WorkID) == "" { + return "", cderr.Invalid("ungrouped session can only write user memory") + } + return place.WorkID, nil default: return "", cderr.Invalid("invalid memory scope") } diff --git a/server/internal/archtest/arch_test.go b/server/internal/archtest/arch_test.go index d48db8a..9336571 100644 --- a/server/internal/archtest/arch_test.go +++ b/server/internal/archtest/arch_test.go @@ -21,6 +21,7 @@ func TestImportDirection(t *testing.T) { }{ {"server/pkg/agent", []string{"codedock/internal"}, "底座核心包不可依赖上层内部包,请将事件通过接口回调注入。", "docs/architecture.md 第 3 节"}, {"server/pkg/git", []string{"codedock/internal", "codedock/pkg/agent"}, "Git CLI 不进 pkg/agent,也不写产品流程。", "docs/architecture.md"}, + {"server/pkg/github", []string{"codedock/internal", "codedock/pkg/agent"}, "gh CLI 不进 pkg/agent,也不落 token。", "docs/architecture.md"}, {"server/pkg/plugin", []string{"codedock/internal"}, "插件 SDK 不依赖 handler 或 internal。", "docs/architecture.md"}, {"server/internal/agent/memory", []string{"codedock/internal/agent"}, "memory 不 import 父包 internal/agent。", "docs/architecture.md"}, {"server/internal/agent/tools", []string{"codedock/internal/agent"}, "tools 不 import 父包 internal/agent。", "docs/architecture.md"}, diff --git a/server/internal/board/board_test.go b/server/internal/board/board_test.go new file mode 100644 index 0000000..45f15d1 --- /dev/null +++ b/server/internal/board/board_test.go @@ -0,0 +1,272 @@ +package board + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "codedock/internal/util" + "codedock/pkg/db" + "codedock/pkg/db/sqlite" +) + +// testQueries 打开一块迁移过的内存库。 +func testQueries(t *testing.T) (*sqlite.Queries, context.Context) { + t.Helper() + ctx := context.Background() + name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + client, err := db.Open(ctx, db.Config{Engine: db.EngineSQLite, DSN: fmt.Sprintf("file:%s?mode=memory&cache=shared", name)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := db.Migrate(ctx, client.DB()); err != nil { + t.Fatal(err) + } + return db.SQLiteQueries(client), ctx +} + +func TestWorkCheckoutPlacement(t *testing.T) { + q, ctx := testQueries(t) + work, err := CreateWork(ctx, q, "t1", "u1", "修登录") + if err != nil { + t.Fatal(err) + } + info, err := GetInfo(ctx, q, work.ID, "") + if err != nil || info.WorkID != work.ID { + t.Fatalf("blank work info: %+v %v", info, err) + } + dir := t.TempDir() + co, err := AttachCheckout(ctx, q, work.ID, dir, CheckoutFolder) + if err != nil { + t.Fatal(err) + } + if _, err := PutInfo(ctx, q, work.ID, co.Path, "这个目录写 API"); err != nil { + t.Fatal(err) + } + if _, err := Bind(ctx, q, Placement{Engine: EngineNative, SessionID: "s-dir", WorkID: work.ID, Checkout: co.Path}); err != nil { + t.Fatal(err) + } + talk, err := AttachExisting(ctx, q, EngineNative, "s-talk", work.ID) + if err != nil { + t.Fatal(err) + } + if talk.Checkout != "" { + t.Fatalf("talk checkout %q", talk.Checkout) + } + bound, err := SetSessionDirectory(ctx, q, EngineNative, "s-talk", dir) + if err != nil || bound == "" { + t.Fatalf("talk session should bind a directory at creation: %q %v", bound, err) + } + talk, err = GetPlacement(ctx, q, EngineNative, "s-talk") + if err != nil || talk.Checkout != bound { + t.Fatalf("bound talk %+v %v", talk, err) + } + if _, err := SetSessionDirectory(ctx, q, EngineNative, "s-talk", ""); err == nil { + t.Fatal("directory cannot be unbound") + } + if _, err := SetSessionDirectory(ctx, q, EngineNative, "s-talk", t.TempDir()); err == nil { + t.Fatal("directory cannot be replaced") + } + talk, err = GetPlacement(ctx, q, EngineNative, "s-talk") + if err != nil || talk.Checkout != bound { + t.Fatalf("directory should stay %+v %v", talk, err) + } + if err := DetachCheckout(ctx, q, work.ID, co.Path); err != nil { + t.Fatal(err) + } + place, err := GetPlacement(ctx, q, EngineNative, "s-dir") + if err != nil { + t.Fatal(err) + } + if place.Checkout != "" { + t.Fatalf("detached session checkout %q", place.Checkout) + } + if HasCheckout(ctx, q, work.ID, dir) { + t.Fatal("detached path should not stay attached") + } + if err := DeleteWork(ctx, q, work.ID); err != nil { + t.Fatal(err) + } + if _, err := GetPlacement(ctx, q, EngineNative, "s-talk"); err == nil { + t.Fatal("delete work should unbind placements") + } +} + +func TestStartTalkAndInDir(t *testing.T) { + q, ctx := testQueries(t) + work, err := CreateWork(ctx, q, "t1", "u1", "卡") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + svc := New(q, Ports{ + StartNative: func(_ context.Context, spec StartSpec) (Started, error) { + id := "talk" + if spec.WorkspaceID != "" { + id = "dir" + } + return Started{ID: id, Summary: spec.WorkspaceID, UpdatedAt: util.FormatTime(util.Now())}, nil + }, + }) + talk, started, err := svc.StartTalk(ctx, work.ID, StartSpec{Engine: EngineNative, UserID: "u1"}) + if err != nil { + t.Fatal(err) + } + if started.ID != "talk" || talk.Checkout != "" { + t.Fatalf("talk %+v %+v", talk, started) + } + inDir, started, err := svc.StartInDir(ctx, work.ID, dir, StartSpec{Engine: EngineNative, UserID: "u1"}) + if err != nil { + t.Fatal(err) + } + if started.ID != "dir" || inDir.Checkout == "" { + t.Fatalf("dir %+v %+v", inDir, started) + } + if _, _, err := svc.StartInDir(ctx, work.ID, filepath.Join(os.TempDir(), "missing-codedock-dir"), StartSpec{Engine: EngineNative}); err == nil { + t.Fatal("missing checkout must fail") + } +} + +func TestApprovalSummary(t *testing.T) { + got := approvalSummary("tools", `[{"name":"bash","arguments":{"command":"ls packages"}}]`) + if got != "bash\nls packages" { + t.Fatalf("summary %q", got) + } + if approvalSummary("verify", "") != "verify" { + t.Fatal("empty calls should keep the kind") + } +} + +func TestAttachCheckoutRequiresExistingDir(t *testing.T) { + q, ctx := testQueries(t) + work, err := CreateWork(ctx, q, "t1", "u1", "卡") + if err != nil { + t.Fatal(err) + } + missing := filepath.Join(os.TempDir(), "codedock-missing-checkout-"+t.Name()) + if _, err := AttachCheckout(ctx, q, work.ID, missing, CheckoutFolder); err == nil { + t.Fatal("missing directory must not attach") + } +} + +func TestBoardCardsAndPacket(t *testing.T) { + q, ctx := testQueries(t) + work, err := CreateWork(ctx, q, "t1", "u1", "卡") + if err != nil { + t.Fatal(err) + } + if _, err := PutInfo(ctx, q, work.ID, "", "卡片说明"); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + co, err := AttachCheckout(ctx, q, work.ID, dir, CheckoutFolder) + if err != nil { + t.Fatal(err) + } + if _, err := Bind(ctx, q, Placement{Engine: EngineNative, SessionID: "s1", WorkID: work.ID, Checkout: co.Path}); err != nil { + t.Fatal(err) + } + if _, err := PutIssue(ctx, q, IssueSnap{Engine: EngineNative, SessionID: "s1", Repo: "acme/app", Number: 15, Title: "看板"}); err != nil { + t.Fatal(err) + } + svc := New(q, Ports{ + InspectDir: func(path string) DirView { + return DirView{Path: path, Branch: "main", Dirty: true, SharedWriters: []string{"/other"}} + }, + ListEngine: func(_ context.Context, engine Engine) ([]SessionMeta, error) { + if engine != EngineClaude { + return nil, nil + } + return []SessionMeta{{ID: "c1", Summary: "claude 闲聊", UpdatedAt: "z"}}, nil + }, + }) + view, err := svc.Cards(ctx, "t1", "u1") + if err != nil { + t.Fatal(err) + } + if len(view.Cards) != 1 || len(view.Cards[0].Sessions) != 1 { + t.Fatalf("cards=%+v", view) + } + sess := view.Cards[0].Sessions[0] + if sess.Checkout == "" || sess.Branch != "main" || !sess.Dirty { + t.Fatalf("session dir %+v", sess) + } + if len(view.Cards[0].Dirs) != 0 { + t.Fatalf("work should not own directories: %+v", view.Cards[0].Dirs) + } + if len(view.Ungrouped) != 1 || view.Ungrouped[0].SessionID != "c1" { + t.Fatalf("ungrouped %+v", view.Ungrouped) + } + pkt, err := BuildPacket(ctx, q, EngineNative, "s1") + if err != nil || pkt.Text == "" || !strings.Contains(pkt.Text, "卡片说明") || !strings.Contains(pkt.Text, "#15") { + t.Fatalf("packet %+v %v", pkt, err) + } + if got := PrefixContent(pkt.Text, "继续"); !strings.Contains(got, "只读") || !strings.Contains(got, "继续") { + t.Fatalf("prefix %q", got) + } +} + +func TestReplaceLinks(t *testing.T) { + q, ctx := testQueries(t) + first, err := ReplaceLinks(ctx, q, EngineNative, "s1", &IssueSnap{ + Engine: EngineNative, SessionID: "s1", Repo: "acme/app", Number: 15, Title: "旧", URL: "https://github.com/acme/app/issues/15", + }, []PullSnap{{ + Engine: EngineNative, SessionID: "s1", Repo: "acme/app", Number: 3, Title: "旧 PR", + }}) + if err != nil || first.Issue == nil || len(first.Pulls) != 1 { + t.Fatalf("first %+v %v", first, err) + } + next, err := ReplaceLinks(ctx, q, EngineNative, "s1", nil, []PullSnap{{ + Engine: EngineNative, SessionID: "s1", Repo: "acme/app", Number: 8, Title: "新 PR", URL: "https://github.com/acme/app/pull/8", + }}) + if err != nil || next.Issue != nil || len(next.Pulls) != 1 || next.Pulls[0].Number != 8 { + t.Fatalf("replaced %+v %v", next, err) + } +} + +func TestWorkTitlesAreUnique(t *testing.T) { + q, ctx := testQueries(t) + first, err := CreateWork(ctx, q, "t1", "u1", "") + if err != nil || first.Title != "未命名(1)" { + t.Fatalf("first %+v %v", first, err) + } + second, err := CreateWork(ctx, q, "t1", "u1", " ") + if err != nil || second.Title != "未命名(2)" { + t.Fatalf("second %+v %v", second, err) + } + if _, err := CreateWork(ctx, q, "t1", "u1", "未命名(1)"); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatal("expected duplicate title") + } + other, err := CreateWork(ctx, q, "t1", "u2", "") + if err != nil || other.Title != "未命名(1)" { + t.Fatalf("other user %+v %v", other, err) + } + if _, err := UpdateWork(ctx, q, second.ID, "未命名(1)"); err == nil { + t.Fatal("expected rename conflict") + } + if _, err := UpdateWork(ctx, q, second.ID, " "); err == nil || !strings.Contains(err.Error(), "required") { + t.Fatal("expected empty rename rejected") + } + renamed, err := UpdateWork(ctx, q, second.ID, "修登录") + if err != nil || renamed.Title != "修登录" { + t.Fatalf("rename %+v %v", renamed, err) + } + third, err := CreateWork(ctx, q, "t1", "u1", "") + if err != nil || third.Title != "未命名(2)" { + t.Fatalf("reuse gap %+v %v", third, err) + } +} + +func TestParseEngine(t *testing.T) { + eng, err := ParseEngine("agent") + if err != nil || eng != EngineNative || PublicEngine(eng) != "agent" { + t.Fatalf("agent %s %v", eng, err) + } + if _, err := ParseEngine("nope"); err == nil { + t.Fatal("expected invalid engine") + } +} diff --git a/server/internal/board/service.go b/server/internal/board/service.go new file mode 100644 index 0000000..00695ab --- /dev/null +++ b/server/internal/board/service.go @@ -0,0 +1,525 @@ +package board + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + cderr "codedock/internal/errors" + "codedock/pkg/agent" + "codedock/pkg/db/sqlite" +) + +const packetBanner = "CodeDock Work Packet — 只读,不要改写这段,也不要抄进记忆。" + +// Ports 是看板编排要问的外部能力:建会话、现问 Git、gh 快照、三引擎问票。 +type Ports struct { + StartNative func(ctx context.Context, spec StartSpec) (Started, error) + StartClaude func(ctx context.Context, spec StartSpec) (Started, error) + StartCodex func(ctx context.Context, spec StartSpec) (Started, error) + Lookup func(ctx context.Context, engine Engine, sessionID string) (SessionMeta, error) + ListEngine func(ctx context.Context, engine Engine) ([]SessionMeta, error) + InspectDir func(path string) DirView + ViewIssue func(repo string, number int) (IssueSnap, error) + ViewPull func(repo string, number int) (PullSnap, error) + ListAsks func(engine Engine, sessionID string) []InboxItem + Decide func(ctx context.Context, engine Engine, ticketID string, answer DecideAnswer) error + ApplyDir func(ctx context.Context, engine Engine, sessionID, path string) error +} + +// Service 编排 Work / 看板聚合 / Inbox;不写记忆、不 spawn CLI、不建 worktree。 +type Service struct { + q *sqlite.Queries + ports Ports +} + +// New 构造看板服务。 +func New(q *sqlite.Queries, ports Ports) *Service { + return &Service{q: q, ports: ports} +} + +// StartTalk 先走现有建会话,再写成问答归属。 +func (s *Service) StartTalk(ctx context.Context, workID string, spec StartSpec) (Placement, Started, error) { + if s == nil || s.q == nil { + return Placement{}, Started{}, cderr.Invalid("board service required") + } + spec.Talk = true + spec.WorkspaceID = "" + started, err := s.startSession(ctx, spec) + if err != nil { + return Placement{}, Started{}, err + } + place, err := Bind(ctx, s.q, Placement{Engine: spec.Engine, SessionID: started.ID, WorkID: workID}) + return place, started, err +} + +// StartInDir 开会话,并把这个已存在的目录绑到新会话上。 +func (s *Service) StartInDir(ctx context.Context, workID, path string, spec StartSpec) (Placement, Started, error) { + if s == nil || s.q == nil { + return Placement{}, Started{}, cderr.Invalid("board service required") + } + cleaned, err := requireExistingDir(path) + if err != nil { + return Placement{}, Started{}, err + } + spec.Talk = false + spec.WorkspaceID = cleaned + started, err := s.startSession(ctx, spec) + if err != nil { + return Placement{}, Started{}, err + } + place, err := Bind(ctx, s.q, Placement{Engine: spec.Engine, SessionID: started.ID, WorkID: workID, Checkout: cleaned}) + return place, started, err +} + +// BindSessionDirectory 在创建会话时记下目录,并交给引擎改工作目录。不能解绑,也不能换成另一个目录。 +func (s *Service) BindSessionDirectory(ctx context.Context, engine Engine, sessionID, path string) (string, error) { + if s == nil || s.q == nil { + return "", cderr.Invalid("board service required") + } + cleaned, err := SetSessionDirectory(ctx, s.q, engine, sessionID, path) + if err != nil { + return "", err + } + if cleaned != "" && s.ports.ApplyDir != nil { + if err := s.ports.ApplyDir(ctx, engine, sessionID, cleaned); err != nil { + return "", err + } + } + return cleaned, nil +} + +// startSession 按引擎走已注入的建会话口。 +func (s *Service) startSession(ctx context.Context, spec StartSpec) (Started, error) { + engine, err := ParseEngine(string(spec.Engine)) + if err != nil { + return Started{}, err + } + spec.Engine = engine + var fn func(context.Context, StartSpec) (Started, error) + switch engine { + case EngineNative: + fn = s.ports.StartNative + case EngineClaude: + fn = s.ports.StartClaude + case EngineCodex: + fn = s.ports.StartCodex + } + if fn == nil { + return Started{}, cderr.Unavailable("session starter for %s is not configured", PublicEngine(engine)) + } + return fn(ctx, spec) +} + +// Cards 聚合用户的横向看板,只带摘要不加载对话正文。 +func (s *Service) Cards(ctx context.Context, tenantID, userID string) (BoardView, error) { + if s == nil || s.q == nil { + return BoardView{}, cderr.Invalid("board service required") + } + works, err := ListWorks(ctx, s.q, tenantID, userID) + if err != nil { + return BoardView{}, err + } + view := BoardView{Cards: make([]Card, 0, len(works)), Ungrouped: []SessionView{}} + placed := map[string]struct{}{} + for _, work := range works { + card, err := s.Card(ctx, work.ID) + if err != nil { + return BoardView{}, err + } + view.Cards = append(view.Cards, card) + for _, sess := range card.Sessions { + placed[sess.Engine+":"+sess.SessionID] = struct{}{} + } + } + ungrouped, err := s.listUngrouped(ctx, tenantID, userID, placed) + if err != nil { + return BoardView{}, err + } + view.Ungrouped = ungrouped + return view, nil +} + +// Card 聚合一张卡的说明与会话摘要。目录挂在会话上,不挂在卡上。 +func (s *Service) Card(ctx context.Context, workID string) (Card, error) { + if s == nil || s.q == nil { + return Card{}, cderr.Invalid("board service required") + } + work, err := GetWork(ctx, s.q, workID) + if err != nil { + return Card{}, err + } + info, err := GetInfo(ctx, s.q, workID, "") + if err != nil { + return Card{}, err + } + card := Card{Work: work, Info: info, Dirs: []DirView{}, Sessions: []SessionView{}} + places, err := ListPlacements(ctx, s.q, workID) + if err != nil { + return Card{}, err + } + for _, place := range places { + view, ok := s.sessionView(ctx, place) + if !ok { + continue + } + card.Sessions = append(card.Sessions, view) + if view.Running { + card.Running++ + } + card.Pending += view.Pending + } + sort.SliceStable(card.Sessions, func(i, j int) bool { return card.Sessions[i].UpdatedAt > card.Sessions[j].UpdatedAt }) + return card, nil +} + +// sessionView 读一路会话的看板摘要,目录取会话自己的绑定。已归档的不进卡。 +func (s *Service) sessionView(ctx context.Context, place Placement) (SessionView, bool) { + view := SessionView{Engine: PublicEngine(place.Engine), SessionID: place.SessionID, Checkout: place.Checkout} + if s.ports.Lookup != nil { + if meta, err := s.ports.Lookup(ctx, place.Engine, place.SessionID); err == nil { + if meta.Archived { + return SessionView{}, false + } + view.Summary = meta.Summary + view.UpdatedAt = meta.UpdatedAt + view.Running = meta.Running + view.Pending = meta.Pending + s.annotateDirectory(ctx, place.Engine, &view) + return view, true + } + } + if place.Engine == EngineNative { + if row, err := s.q.GetSession(ctx, place.SessionID); err == nil { + if row.Status == string(agent.SessionArchived) { + return SessionView{}, false + } + view.Summary = row.Summary + view.UpdatedAt = row.UpdatedAt + view.Running = nativeExecuting(ctx, s.q, row.ActiveRunID.String) + if n, err := s.q.CountPendingApprovalsBySession(ctx, place.SessionID); err == nil { + view.Pending = int(n) + } + } + } + s.annotateDirectory(ctx, place.Engine, &view) + return view, true +} + +// nativeExecuting 用 Run 状态判断是否还在执行。没有 Run、在等审批或已结束时为 false。 +func nativeExecuting(ctx context.Context, q *sqlite.Queries, activeRunID string) bool { + if q == nil || activeRunID == "" { + return false + } + row, err := q.GetRun(ctx, activeRunID) + if err != nil { + return false + } + return agent.IsExecuting(agent.RunStatus(row.Status)) +} + +// annotateDirectory 用会话目录盖过归属上的路径,并现问 Git 状态。 +func (s *Service) annotateDirectory(ctx context.Context, engine Engine, view *SessionView) { + if view == nil || s == nil || s.q == nil { + return + } + if path, err := GetSessionDirectory(ctx, s.q, engine, view.SessionID); err == nil && path != "" { + view.Checkout = path + } + if view.Checkout == "" || s.ports.InspectDir == nil { + return + } + inspected := s.ports.InspectDir(view.Checkout) + view.Branch = inspected.Branch + view.Dirty = inspected.Dirty +} + +// listUngrouped 列出没有 placement 的三引擎会话。 +func (s *Service) listUngrouped(ctx context.Context, tenantID, userID string, placed map[string]struct{}) ([]SessionView, error) { + var out []SessionView + rows, err := s.q.ListUngroupedNativeSessions(ctx, sqlite.ListUngroupedNativeSessionsParams{TenantID: tenantID, UserID: userID}) + if err != nil { + return nil, err + } + for _, row := range rows { + view := SessionView{ + Engine: PublicEngine(EngineNative), SessionID: row.ID, Summary: row.Summary, UpdatedAt: row.UpdatedAt, + } + if s.ports.Lookup != nil { + if meta, err := s.ports.Lookup(ctx, EngineNative, row.ID); err == nil { + if meta.Archived { + continue + } + view.Summary = meta.Summary + view.UpdatedAt = meta.UpdatedAt + view.Running = meta.Running + view.Pending = meta.Pending + s.annotateDirectory(ctx, EngineNative, &view) + out = append(out, view) + continue + } + } + view.Running = nativeExecuting(ctx, s.q, row.ActiveRunID.String) + if n, err := s.q.CountPendingApprovalsBySession(ctx, row.ID); err == nil { + view.Pending = int(n) + } + s.annotateDirectory(ctx, EngineNative, &view) + out = append(out, view) + } + for _, engine := range []Engine{EngineClaude, EngineCodex} { + if s.ports.ListEngine == nil { + continue + } + metas, err := s.ports.ListEngine(ctx, engine) + if err != nil { + continue + } + for _, meta := range metas { + if meta.Archived { + continue + } + if _, ok := placed[PublicEngine(engine)+":"+meta.ID]; ok { + continue + } + view := SessionView{ + Engine: PublicEngine(engine), SessionID: meta.ID, Summary: meta.Summary, + UpdatedAt: meta.UpdatedAt, Running: meta.Running, Pending: meta.Pending, + } + s.annotateDirectory(ctx, engine, &view) + out = append(out, view) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out, nil +} + +// ListInbox 扫该卡下三引擎待批。 +func (s *Service) ListInbox(ctx context.Context, workID string) ([]InboxItem, error) { + if s == nil || s.q == nil { + return nil, cderr.Invalid("board service required") + } + places, err := ListPlacements(ctx, s.q, workID) + if err != nil { + return nil, err + } + var items []InboxItem + for _, place := range places { + if place.Engine == EngineNative { + items = append(items, s.nativeInbox(ctx, place.SessionID)...) + continue + } + if s.ports.ListAsks != nil { + items = append(items, s.ports.ListAsks(place.Engine, place.SessionID)...) + } + } + if items == nil { + items = []InboxItem{} + } + return items, nil +} + +// DecideInbox 按引擎把裁决转给已有审批入口。 +func (s *Service) DecideInbox(ctx context.Context, engine Engine, ticketID string, answer DecideAnswer) error { + if s == nil { + return cderr.Invalid("board service required") + } + if ticketID == "" { + return cderr.Invalid("ticket_id is required") + } + if s.ports.Decide == nil { + return cderr.Unavailable("inbox decide is not configured") + } + return s.ports.Decide(ctx, engine, ticketID, answer) +} + +// nativeInbox 扫自有 Agent 待批,payload 保持原审批。 +func (s *Service) nativeInbox(ctx context.Context, sessionID string) []InboxItem { + rows, err := s.q.ListPendingApprovalsBySession(ctx, sessionID) + if err != nil { + return nil + } + items := make([]InboxItem, 0, len(rows)) + for _, row := range rows { + raw, _ := json.Marshal(map[string]any{ + "id": row.ID, "session_id": row.SessionID, "run_id": row.RunID, + "status": row.Status, "kind": row.Kind, "tool_calls": json.RawMessage(row.ToolCalls), + }) + items = append(items, InboxItem{ + Engine: PublicEngine(EngineNative), SessionID: sessionID, TicketID: row.ID, + Summary: approvalSummary(row.Kind, row.ToolCalls), Payload: raw, + }) + } + return items +} + +// approvalSummary 把待批工具收成看板能看懂的短句:工具名加命令、路径或搜索词。 +func approvalSummary(kind, toolCalls string) string { + var calls []struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal([]byte(toolCalls), &calls); err != nil || len(calls) == 0 { + if strings.TrimSpace(kind) == "" { + return "审批" + } + return kind + } + parts := make([]string, 0, len(calls)) + for _, call := range calls { + name := strings.TrimSpace(call.Name) + if name == "" { + name = "工具" + } + detail := argumentDetail(call.Arguments) + if detail == "" { + parts = append(parts, name) + continue + } + parts = append(parts, name+"\n"+detail) + } + return strings.Join(parts, "\n\n") +} + +// argumentDetail 从工具参数里取出命令、路径或搜索词。 +func argumentDetail(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return argumentDetail(json.RawMessage(asString)) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + return strings.Trim(string(raw), `"`) + } + if command, _ := fields["command"].(string); strings.TrimSpace(command) != "" { + return strings.TrimSpace(command) + } + path, _ := fields["path"].(string) + pattern, _ := fields["pattern"].(string) + path = strings.TrimSpace(path) + pattern = strings.TrimSpace(pattern) + if path != "" && pattern != "" { + return pattern + " " + path + } + if path != "" { + return path + } + if pattern != "" { + return pattern + } + compact, err := json.Marshal(fields) + if err != nil || string(compact) == "{}" { + return "" + } + return string(compact) +} + +// BuildPacket 组装 Work Info、目录 Info、Issue/PR 快照。 +func BuildPacket(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) (Packet, error) { + if q == nil { + return Packet{}, nil + } + pkt := Packet{Pulls: []PullSnap{}} + dirPath := "" + if path, err := GetSessionDirectory(ctx, q, engine, sessionID); err == nil { + dirPath = path + } + if place, err := GetPlacement(ctx, q, engine, sessionID); err == nil { + if info, infoErr := GetInfo(ctx, q, place.WorkID, ""); infoErr == nil { + pkt.WorkInfo = info + } + if dirPath == "" { + dirPath = place.Checkout + } + if dirPath != "" { + dir, dirErr := GetInfo(ctx, q, place.WorkID, dirPath) + if dirErr != nil { + dir = Info{WorkID: place.WorkID, Checkout: dirPath} + } + dir.Checkout = dirPath + pkt.DirInfo = &dir + } + } else if dirPath != "" { + pkt.DirInfo = &Info{Checkout: dirPath} + } + if links, err := GetLinks(ctx, q, engine, sessionID); err == nil { + pkt.Issue = links.Issue + pkt.Pulls = links.Pulls + } + pkt.Text = FormatPacket(pkt) + return pkt, nil +} + +// FormatPacket 把 Packet 收成独立 system / 只读前缀文本。 +func FormatPacket(pkt Packet) string { + var b strings.Builder + b.WriteString(packetBanner) + wrote := false + if strings.TrimSpace(pkt.WorkInfo.Body) != "" { + b.WriteString("\n\n## Work\n") + b.WriteString(strings.TrimSpace(pkt.WorkInfo.Body)) + wrote = true + } + if pkt.DirInfo != nil && strings.TrimSpace(pkt.DirInfo.Checkout) != "" { + b.WriteString("\n\n## 目录 ") + b.WriteString(pkt.DirInfo.Checkout) + if body := strings.TrimSpace(pkt.DirInfo.Body); body != "" { + b.WriteByte('\n') + b.WriteString(body) + } + wrote = true + } + if pkt.Issue != nil && (pkt.Issue.Number > 0 || pkt.Issue.Title != "") { + b.WriteString("\n\n## Issue") + if pkt.Issue.Repo != "" { + b.WriteByte(' ') + b.WriteString(pkt.Issue.Repo) + } + b.WriteString(fmt.Sprintf(" #%d %s", pkt.Issue.Number, pkt.Issue.Title)) + if pkt.Issue.State != "" { + b.WriteString(" (" + pkt.Issue.State + ")") + } + if strings.TrimSpace(pkt.Issue.Body) != "" { + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(pkt.Issue.Body)) + } + wrote = true + } + for _, pull := range pkt.Pulls { + b.WriteString("\n\n## Pull request") + if pull.Repo != "" { + b.WriteByte(' ') + b.WriteString(pull.Repo) + } + b.WriteString(fmt.Sprintf(" #%d %s", pull.Number, pull.Title)) + if pull.State != "" { + b.WriteString(" (" + pull.State + ")") + } + if strings.TrimSpace(pull.Body) != "" { + b.WriteByte('\n') + b.WriteString(strings.TrimSpace(pull.Body)) + } + wrote = true + } + if !wrote { + return "" + } + return strings.TrimSpace(b.String()) +} + +// PrefixContent 把 Packet 标成只读前缀再交给 Claude / Codex 现有 turn API。 +func PrefixContent(packet, content string) string { + packet = strings.TrimSpace(packet) + content = strings.TrimSpace(content) + if packet == "" { + return content + } + if content == "" { + return packet + } + return packet + "\n\n---\n\n" + content +} diff --git a/server/internal/board/store.go b/server/internal/board/store.go new file mode 100644 index 0000000..163768a --- /dev/null +++ b/server/internal/board/store.go @@ -0,0 +1,688 @@ +package board + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + cderr "codedock/internal/errors" + "codedock/internal/util" + "codedock/pkg/db/sqlite" +) + +// CreateWork 建一张卡并挂空白 Work 级 Info。 +func CreateWork(ctx context.Context, q *sqlite.Queries, tenantID, userID, title string) (Work, error) { + if q == nil { + return Work{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(userID) == "" { + return Work{}, cderr.Invalid("user_id is required") + } + if strings.TrimSpace(tenantID) == "" { + tenantID = "default" + } + title, err := assignWorkTitle(ctx, q, tenantID, userID, title, "") + if err != nil { + return Work{}, err + } + now := util.FormatTime(util.Now()) + row, err := q.InsertWork(ctx, sqlite.InsertWorkParams{ + ID: util.NewID(), TenantID: tenantID, UserID: userID, Title: title, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return Work{}, err + } + if _, err := q.UpsertWorkInfo(ctx, sqlite.UpsertWorkInfoParams{WorkID: row.ID, Checkout: "", Body: "", UpdatedAt: now}); err != nil { + return Work{}, err + } + return mapWork(row), nil +} + +// GetWork 读取一张卡。 +func GetWork(ctx context.Context, q *sqlite.Queries, workID string) (Work, error) { + if q == nil { + return Work{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(workID) == "" { + return Work{}, cderr.Invalid("work_id is required") + } + row, err := q.GetWork(ctx, workID) + if err != nil { + return Work{}, wrapDB(err) + } + return mapWork(row), nil +} + +// ListWorks 列出用户的卡,按更新时间倒序。 +func ListWorks(ctx context.Context, q *sqlite.Queries, tenantID, userID string) ([]Work, error) { + if q == nil { + return nil, cderr.Invalid("queries required") + } + if strings.TrimSpace(userID) == "" { + return nil, cderr.Invalid("user_id is required") + } + if strings.TrimSpace(tenantID) == "" { + tenantID = "default" + } + rows, err := q.ListWorksByUser(ctx, sqlite.ListWorksByUserParams{TenantID: tenantID, UserID: userID}) + if err != nil { + return nil, err + } + out := make([]Work, 0, len(rows)) + for _, row := range rows { + out = append(out, mapWork(row)) + } + return out, nil +} + +// UpdateWork 改卡片标题。 +func UpdateWork(ctx context.Context, q *sqlite.Queries, workID, title string) (Work, error) { + current, err := GetWork(ctx, q, workID) + if err != nil { + return Work{}, err + } + title, err = assignWorkTitle(ctx, q, current.TenantID, current.UserID, title, workID) + if err != nil { + return Work{}, err + } + row, err := q.UpdateWork(ctx, sqlite.UpdateWorkParams{Title: title, UpdatedAt: util.FormatTime(util.Now()), ID: workID}) + if err != nil { + return Work{}, err + } + return mapWork(row), nil +} + +// assignWorkTitle 空白标题顺延为未命名(1)、未命名(2);已有同名则拒绝。exceptID 是改名时自己的卡。 +func assignWorkTitle(ctx context.Context, q *sqlite.Queries, tenantID, userID, title, exceptID string) (string, error) { + title = strings.TrimSpace(title) + works, err := ListWorks(ctx, q, tenantID, userID) + if err != nil { + return "", err + } + if title == "" { + if exceptID != "" { + return "", cderr.Invalid("title is required") + } + title = nextUntitled(works) + } + for _, work := range works { + if work.ID == exceptID { + continue + } + if work.Title == title { + return "", cderr.Conflict("work title already exists") + } + } + return title, nil +} + +// nextUntitled 取最小的未占用序号,从 1 开始。 +func nextUntitled(works []Work) string { + used := map[int]struct{}{} + for _, work := range works { + if n, ok := untitledNumber(work.Title); ok { + used[n] = struct{}{} + } + } + for n := 1; ; n++ { + if _, ok := used[n]; !ok { + return fmt.Sprintf("未命名(%d)", n) + } + } +} + +// untitledNumber 解析未命名(n)。不是这个格式则不算占号。 +func untitledNumber(title string) (int, bool) { + const prefix = "未命名(" + if !strings.HasPrefix(title, prefix) || !strings.HasSuffix(title, ")") { + return 0, false + } + n, err := strconv.Atoi(strings.TrimSuffix(title[len(prefix):], ")")) + if err != nil || n < 1 { + return 0, false + } + return n, true +} + +// DeleteWork 删卡并断开归属;会话回未归组,不删会话、不删磁盘。 +func DeleteWork(ctx context.Context, q *sqlite.Queries, workID string) error { + if _, err := GetWork(ctx, q, workID); err != nil { + return err + } + if err := q.DeletePlacementsByWork(ctx, workID); err != nil { + return err + } + if err := q.DeleteWorkInfos(ctx, workID); err != nil { + return err + } + if err := q.DeleteWorkCheckouts(ctx, workID); err != nil { + return err + } + return q.DeleteWork(ctx, workID) +} + +// AttachCheckout 把已存在的目录挂到卡上,并建空白目录级 Info。 +func AttachCheckout(ctx context.Context, q *sqlite.Queries, workID, path string, kind CheckoutKind) (Checkout, error) { + if _, err := GetWork(ctx, q, workID); err != nil { + return Checkout{}, err + } + cleaned, err := requireExistingDir(path) + if err != nil { + return Checkout{}, err + } + if kind == "" { + kind = CheckoutFolder + } + if _, err := ParseCheckoutKind(string(kind)); err != nil { + return Checkout{}, err + } + if _, err := q.GetWorkCheckout(ctx, sqlite.GetWorkCheckoutParams{WorkID: workID, Path: cleaned}); err == nil { + return Checkout{}, cderr.Conflict("checkout already attached") + } + row, err := q.InsertWorkCheckout(ctx, sqlite.InsertWorkCheckoutParams{WorkID: workID, Path: cleaned, Kind: string(kind)}) + if err != nil { + return Checkout{}, err + } + if _, err := q.UpsertWorkInfo(ctx, sqlite.UpsertWorkInfoParams{WorkID: workID, Checkout: cleaned, Body: "", UpdatedAt: util.FormatTime(util.Now())}); err != nil { + return Checkout{}, err + } + touchWork(ctx, q, workID) + return mapCheckout(row), nil +} + +// DetachCheckout 卸下目录:该路径上的会话变成同卡问答。 +func DetachCheckout(ctx context.Context, q *sqlite.Queries, workID, path string) error { + if _, err := GetWork(ctx, q, workID); err != nil { + return err + } + cleaned, err := normalizeCheckoutPath(path) + if err != nil { + return err + } + if _, err := q.GetWorkCheckout(ctx, sqlite.GetWorkCheckoutParams{WorkID: workID, Path: cleaned}); err != nil { + return wrapDB(err) + } + if err := q.ClearPlacementCheckout(ctx, sqlite.ClearPlacementCheckoutParams{WorkID: workID, Checkout: cleaned}); err != nil { + return err + } + if err := q.DeleteWorkInfo(ctx, sqlite.DeleteWorkInfoParams{WorkID: workID, Checkout: cleaned}); err != nil { + return err + } + if err := q.DeleteWorkCheckout(ctx, sqlite.DeleteWorkCheckoutParams{WorkID: workID, Path: cleaned}); err != nil { + return err + } + touchWork(ctx, q, workID) + return nil +} + +// ListCheckouts 列出卡上已挂目录。 +func ListCheckouts(ctx context.Context, q *sqlite.Queries, workID string) ([]Checkout, error) { + if _, err := GetWork(ctx, q, workID); err != nil { + return nil, err + } + rows, err := q.ListWorkCheckouts(ctx, workID) + if err != nil { + return nil, err + } + out := make([]Checkout, 0, len(rows)) + for _, row := range rows { + out = append(out, mapCheckout(row)) + } + return out, nil +} + +// HasCheckout 判断该路径是否仍挂在卡上。 +func HasCheckout(ctx context.Context, q *sqlite.Queries, workID, path string) bool { + cleaned, err := normalizeCheckoutPath(path) + if err != nil { + return false + } + _, err = q.GetWorkCheckout(ctx, sqlite.GetWorkCheckoutParams{WorkID: workID, Path: cleaned}) + return err == nil +} + +// GetInfo 读 Work 级或目录级说明。 +func GetInfo(ctx context.Context, q *sqlite.Queries, workID, checkout string) (Info, error) { + if _, err := GetWork(ctx, q, workID); err != nil { + return Info{}, err + } + key, err := infoCheckoutKey(checkout) + if err != nil { + return Info{}, err + } + row, err := q.GetWorkInfo(ctx, sqlite.GetWorkInfoParams{WorkID: workID, Checkout: key}) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Info{WorkID: workID, Checkout: key}, nil + } + return Info{}, err + } + return mapInfo(row), nil +} + +// PutInfo 覆盖写入说明。 +func PutInfo(ctx context.Context, q *sqlite.Queries, workID, checkout, body string) (Info, error) { + if _, err := GetWork(ctx, q, workID); err != nil { + return Info{}, err + } + key, err := infoCheckoutKey(checkout) + if err != nil { + return Info{}, err + } + if key != "" && !HasCheckout(ctx, q, workID, key) { + return Info{}, cderr.Invalid("checkout is not attached to this work") + } + row, err := q.UpsertWorkInfo(ctx, sqlite.UpsertWorkInfoParams{WorkID: workID, Checkout: key, Body: body, UpdatedAt: util.FormatTime(util.Now())}) + if err != nil { + return Info{}, err + } + touchWork(ctx, q, workID) + return mapInfo(row), nil +} + +// Bind 把一路已建会话挂到卡上。目录属于会话:有路径就记到会话上,空路径沿用会话已有目录。 +func Bind(ctx context.Context, q *sqlite.Queries, place Placement) (Placement, error) { + if q == nil { + return Placement{}, cderr.Invalid("queries required") + } + if _, err := GetWork(ctx, q, place.WorkID); err != nil { + return Placement{}, err + } + if strings.TrimSpace(place.SessionID) == "" { + return Placement{}, cderr.Invalid("session_id is required") + } + if place.Engine == "" { + place.Engine = EngineNative + } + engine, err := ParseEngine(string(place.Engine)) + if err != nil { + return Placement{}, err + } + place.Engine = engine + checkout, err := sessionCheckout(ctx, q, engine, place.SessionID, place.Checkout) + if err != nil { + return Placement{}, err + } + if _, err := GetPlacement(ctx, q, engine, place.SessionID); err == nil { + row, err := q.UpdateSessionPlacement(ctx, sqlite.UpdateSessionPlacementParams{ + WorkID: place.WorkID, Checkout: checkout, Engine: string(engine), SessionID: place.SessionID, + }) + if err != nil { + return Placement{}, err + } + touchWork(ctx, q, place.WorkID) + return mapPlacement(row), nil + } + row, err := q.InsertSessionPlacement(ctx, sqlite.InsertSessionPlacementParams{ + Engine: string(engine), SessionID: place.SessionID, WorkID: place.WorkID, Checkout: checkout, + }) + if err != nil { + return Placement{}, err + } + touchWork(ctx, q, place.WorkID) + return mapPlacement(row), nil +} + +// SetSessionDirectory 在创建会话时记下目录。空路径不能解绑;已有目录不能换成另一个。已归组时同步 placement.checkout。 +func SetSessionDirectory(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID, path string) (string, error) { + if q == nil { + return "", cderr.Invalid("queries required") + } + engine, err := ParseEngine(string(engine)) + if err != nil { + return "", err + } + if strings.TrimSpace(sessionID) == "" { + return "", cderr.Invalid("session_id is required") + } + if strings.TrimSpace(path) == "" { + return "", cderr.Invalid("directory cannot be unbound") + } + cleaned, err := requireExistingDir(path) + if err != nil { + return "", err + } + if current, getErr := GetSessionDirectory(ctx, q, engine, sessionID); getErr == nil && current != "" && current != cleaned { + return "", cderr.Conflict("directory is bound when the session is created") + } else if getErr != nil && !cderr.IsNotFound(getErr) { + return "", getErr + } + if _, err := q.UpsertSessionDirectory(ctx, sqlite.UpsertSessionDirectoryParams{ + Engine: string(engine), SessionID: sessionID, Path: cleaned, + }); err != nil { + return "", err + } + if place, err := GetPlacement(ctx, q, engine, sessionID); err == nil { + if _, err := q.UpdateSessionPlacement(ctx, sqlite.UpdateSessionPlacementParams{ + WorkID: place.WorkID, Checkout: cleaned, Engine: string(engine), SessionID: sessionID, + }); err != nil { + return "", err + } + touchWork(ctx, q, place.WorkID) + } + return cleaned, nil +} + +// GetSessionDirectory 读会话自己的目录;没有行表示还没绑定。 +func GetSessionDirectory(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) (string, error) { + if q == nil { + return "", cderr.Invalid("queries required") + } + engine, err := ParseEngine(string(engine)) + if err != nil { + return "", err + } + if strings.TrimSpace(sessionID) == "" { + return "", cderr.Invalid("session_id is required") + } + row, err := q.GetSessionDirectory(ctx, sqlite.GetSessionDirectoryParams{Engine: string(engine), SessionID: sessionID}) + if err != nil { + return "", wrapDB(err) + } + return row.Path, nil +} + +// sessionCheckout 解析要写入归属的目录:显式路径必须存在,空则沿用会话已绑定的目录。 +func sessionCheckout(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID, checkout string) (string, error) { + if strings.TrimSpace(checkout) != "" { + cleaned, err := requireExistingDir(checkout) + if err != nil { + return "", err + } + if _, err := q.UpsertSessionDirectory(ctx, sqlite.UpsertSessionDirectoryParams{ + Engine: string(engine), SessionID: sessionID, Path: cleaned, + }); err != nil { + return "", err + } + return cleaned, nil + } + path, err := GetSessionDirectory(ctx, q, engine, sessionID) + if err == nil { + return path, nil + } + if cderr.IsNotFound(err) { + return "", nil + } + return "", err +} + +// requireExistingDir 把路径收成绝对目录,必须已经存在。 +func requireExistingDir(path string) (string, error) { + cleaned, err := normalizeCheckoutPath(path) + if err != nil { + return "", err + } + info, err := os.Stat(cleaned) + if err != nil { + return "", cderr.Invalid("checkout path must exist") + } + if !info.IsDir() { + return "", cderr.Invalid("checkout path must be a directory") + } + return cleaned, nil +} + +// AttachExisting 把未归组会话补挂到卡上,只能当问答。 +func AttachExisting(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID, workID string) (Placement, error) { + if _, err := GetPlacement(ctx, q, engine, sessionID); err == nil { + return Placement{}, cderr.Conflict("session already placed") + } + return Bind(ctx, q, Placement{Engine: engine, SessionID: sessionID, WorkID: workID}) +} + +// Unbind 断开归属,会话回未归组。 +func Unbind(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) error { + if _, err := GetPlacement(ctx, q, engine, sessionID); err != nil { + return err + } + return q.DeleteSessionPlacement(ctx, sqlite.DeleteSessionPlacementParams{Engine: string(engine), SessionID: sessionID}) +} + +// GetPlacement 读一路会话的归属;没有行表示未归组。 +func GetPlacement(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) (Placement, error) { + if q == nil { + return Placement{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(sessionID) == "" { + return Placement{}, cderr.Invalid("session_id is required") + } + row, err := q.GetSessionPlacement(ctx, sqlite.GetSessionPlacementParams{Engine: string(engine), SessionID: sessionID}) + if err != nil { + return Placement{}, wrapDB(err) + } + return mapPlacement(row), nil +} + +// ListPlacements 列出一张卡下的全部归属。 +func ListPlacements(ctx context.Context, q *sqlite.Queries, workID string) ([]Placement, error) { + if _, err := GetWork(ctx, q, workID); err != nil { + return nil, err + } + rows, err := q.ListPlacementsByWork(ctx, workID) + if err != nil { + return nil, err + } + return mapPlacements(rows), nil +} + +// ListUserPlacements 列出用户所有卡上的归属,给侧栏分组。 +func ListUserPlacements(ctx context.Context, q *sqlite.Queries, tenantID, userID string) ([]Placement, error) { + if q == nil { + return nil, cderr.Invalid("queries required") + } + if strings.TrimSpace(userID) == "" { + return nil, cderr.Invalid("user_id is required") + } + if strings.TrimSpace(tenantID) == "" { + tenantID = "default" + } + rows, err := q.ListPlacementsByUser(ctx, sqlite.ListPlacementsByUserParams{TenantID: tenantID, UserID: userID}) + if err != nil { + return nil, err + } + return mapPlacements(rows), nil +} + +// GetLinks 读会话头上的 Issue/PR 快照。 +func GetLinks(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) (SessionLinks, error) { + if q == nil { + return SessionLinks{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(sessionID) == "" { + return SessionLinks{}, cderr.Invalid("session_id is required") + } + out := SessionLinks{Pulls: []PullSnap{}} + if issue, err := q.GetSessionIssue(ctx, sqlite.GetSessionIssueParams{Engine: string(engine), SessionID: sessionID}); err == nil { + snap := mapIssue(issue) + out.Issue = &snap + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + return SessionLinks{}, err + } + rows, err := q.ListSessionPulls(ctx, sqlite.ListSessionPullsParams{Engine: string(engine), SessionID: sessionID}) + if err != nil { + return SessionLinks{}, err + } + for _, row := range rows { + out.Pulls = append(out.Pulls, mapPull(row)) + } + return out, nil +} + +// PutIssue 写入 Issue 快照。 +func PutIssue(ctx context.Context, q *sqlite.Queries, snap IssueSnap) (IssueSnap, error) { + if q == nil { + return IssueSnap{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(snap.SessionID) == "" { + return IssueSnap{}, cderr.Invalid("session_id is required") + } + if snap.Number <= 0 { + return IssueSnap{}, cderr.Invalid("issue number is required") + } + row, err := q.UpsertSessionIssue(ctx, sqlite.UpsertSessionIssueParams{ + Engine: string(snap.Engine), SessionID: snap.SessionID, Repo: strings.TrimSpace(snap.Repo), + Number: int64(snap.Number), Title: snap.Title, Body: snap.Body, Url: snap.URL, State: snap.State, + UpdatedAt: util.FormatTime(util.Now()), + }) + if err != nil { + return IssueSnap{}, err + } + return mapIssue(row), nil +} + +// DeleteIssue 去掉会话上的 Issue。 +func DeleteIssue(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string) error { + if q == nil { + return cderr.Invalid("queries required") + } + return q.DeleteSessionIssue(ctx, sqlite.DeleteSessionIssueParams{Engine: string(engine), SessionID: sessionID}) +} + +// PutPull 写入或覆盖一条 PR 快照。 +func PutPull(ctx context.Context, q *sqlite.Queries, snap PullSnap) (PullSnap, error) { + if q == nil { + return PullSnap{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(snap.SessionID) == "" { + return PullSnap{}, cderr.Invalid("session_id is required") + } + if snap.Number <= 0 { + return PullSnap{}, cderr.Invalid("pull number is required") + } + row, err := q.UpsertSessionPull(ctx, sqlite.UpsertSessionPullParams{ + Engine: string(snap.Engine), SessionID: snap.SessionID, Repo: strings.TrimSpace(snap.Repo), + Number: int64(snap.Number), Title: snap.Title, Body: snap.Body, Url: snap.URL, State: snap.State, + UpdatedAt: util.FormatTime(util.Now()), + }) + if err != nil { + return PullSnap{}, err + } + return mapPull(row), nil +} + +// ReplaceLinks 用一批快照换掉会话上的全部 Issue/PR。 +func ReplaceLinks(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID string, issue *IssueSnap, pulls []PullSnap) (SessionLinks, error) { + if q == nil { + return SessionLinks{}, cderr.Invalid("queries required") + } + if strings.TrimSpace(sessionID) == "" { + return SessionLinks{}, cderr.Invalid("session_id is required") + } + if err := DeleteIssue(ctx, q, engine, sessionID); err != nil { + return SessionLinks{}, err + } + if err := q.DeleteSessionPulls(ctx, sqlite.DeleteSessionPullsParams{Engine: string(engine), SessionID: sessionID}); err != nil { + return SessionLinks{}, err + } + out := SessionLinks{Pulls: []PullSnap{}} + if issue != nil { + saved, err := PutIssue(ctx, q, *issue) + if err != nil { + return SessionLinks{}, err + } + out.Issue = &saved + } + for _, pull := range pulls { + saved, err := PutPull(ctx, q, pull) + if err != nil { + return SessionLinks{}, err + } + out.Pulls = append(out.Pulls, saved) + } + return out, nil +} + +// DeletePull 去掉会话上的一条 PR。 +func DeletePull(ctx context.Context, q *sqlite.Queries, engine Engine, sessionID, repo string, number int) error { + if q == nil { + return cderr.Invalid("queries required") + } + return q.DeleteSessionPull(ctx, sqlite.DeleteSessionPullParams{ + Engine: string(engine), SessionID: sessionID, Repo: repo, Number: int64(number), + }) +} + +// mapWork 把 sqlc 行收成领域对象。 +func mapWork(row sqlite.Work) Work { + return Work{ID: row.ID, TenantID: row.TenantID, UserID: row.UserID, Title: row.Title, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +// mapCheckout 把目录行收成 Checkout。 +func mapCheckout(row sqlite.WorkCheckout) Checkout { + return Checkout{WorkID: row.WorkID, Path: row.Path, Kind: CheckoutKind(row.Kind)} +} + +// mapInfo 把说明行收成 Info。 +func mapInfo(row sqlite.WorkInfo) Info { + return Info{WorkID: row.WorkID, Checkout: row.Checkout, Body: row.Body, UpdatedAt: row.UpdatedAt} +} + +// mapPlacement 把归属行收成 Placement。 +func mapPlacement(row sqlite.SessionPlacement) Placement { + return Placement{Engine: Engine(row.Engine), SessionID: row.SessionID, WorkID: row.WorkID, Checkout: row.Checkout} +} + +// mapPlacements 批量映射归属。 +func mapPlacements(rows []sqlite.SessionPlacement) []Placement { + out := make([]Placement, 0, len(rows)) + for _, row := range rows { + out = append(out, mapPlacement(row)) + } + return out +} + +// mapIssue 把 Issue 行收成快照。 +func mapIssue(row sqlite.SessionIssue) IssueSnap { + return IssueSnap{Engine: Engine(row.Engine), SessionID: row.SessionID, Repo: row.Repo, Number: int(row.Number), Title: row.Title, Body: row.Body, URL: row.Url, State: row.State, UpdatedAt: row.UpdatedAt} +} + +// mapPull 把 PR 行收成快照。 +func mapPull(row sqlite.SessionPull) PullSnap { + return PullSnap{Engine: Engine(row.Engine), SessionID: row.SessionID, Repo: row.Repo, Number: int(row.Number), Title: row.Title, Body: row.Body, URL: row.Url, State: row.State, UpdatedAt: row.UpdatedAt} +} + +// wrapDB 把 sql.ErrNoRows 收成 NotFound。 +func wrapDB(err error) error { + if err == nil { + return nil + } + if errors.Is(err, sql.ErrNoRows) { + return cderr.NotFound("%s", err.Error()) + } + return err +} + +// touchWork 刷新卡片 updated_at。 +func touchWork(ctx context.Context, q *sqlite.Queries, workID string) { + if q == nil || workID == "" { + return + } + _ = q.TouchWork(ctx, sqlite.TouchWorkParams{UpdatedAt: util.FormatTime(util.Now()), ID: workID}) +} + +// normalizeCheckoutPath 收成绝对干净路径。 +func normalizeCheckoutPath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", cderr.Invalid("checkout path is required") + } + abs, err := filepath.Abs(trimmed) + if err != nil { + return "", cderr.Invalid("invalid checkout path") + } + return filepath.Clean(abs), nil +} + +// infoCheckoutKey 空字符串表示 Work 级 Info。 +func infoCheckoutKey(checkout string) (string, error) { + trimmed := strings.TrimSpace(checkout) + if trimmed == "" { + return "", nil + } + return normalizeCheckoutPath(trimmed) +} diff --git a/server/internal/board/types.go b/server/internal/board/types.go new file mode 100644 index 0000000..81ac00b --- /dev/null +++ b/server/internal/board/types.go @@ -0,0 +1,242 @@ +package board + +import ( + "encoding/json" + "strings" + + cderr "codedock/internal/errors" +) + +// Engine 是会话归属引擎在库内的名字。 +type Engine string + +const ( + EngineNative Engine = "native" // 自有 Agent;HTTP / 前端写成 agent。 + EngineClaude Engine = "claude" + EngineCodex Engine = "codex" +) + +// CheckoutKind 是挂到 Work 上的目录种类。 +type CheckoutKind string + +const ( + CheckoutFolder CheckoutKind = "folder" + CheckoutPrimary CheckoutKind = "primary" + CheckoutWorktree CheckoutKind = "worktree" +) + +// Work 是一张看板卡,可挂多目录、多路会话。 +type Work struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Info 是 Work 级或目录级说明;Checkout 空表示卡片本身。 +type Info struct { + WorkID string `json:"work_id"` + Checkout string `json:"checkout"` + Body string `json:"body"` + UpdatedAt string `json:"updated_at"` +} + +// Checkout 是挂在一张卡上的目录。 +type Checkout struct { + WorkID string `json:"work_id"` + Path string `json:"path"` + Kind CheckoutKind `json:"kind"` +} + +// Placement 把一路会话挂到一张卡;Checkout 空表示问答。 +type Placement struct { + Engine Engine `json:"engine"` + SessionID string `json:"session_id"` + WorkID string `json:"work_id"` + Checkout string `json:"checkout"` +} + +// IssueSnap 是会话挂上的 Issue 快照,来自本机 gh。 +type IssueSnap struct { + Engine Engine `json:"engine"` + SessionID string `json:"session_id"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + UpdatedAt string `json:"updated_at"` +} + +// PullSnap 是会话挂上的一条 PR 快照。 +type PullSnap struct { + Engine Engine `json:"engine"` + SessionID string `json:"session_id"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + UpdatedAt string `json:"updated_at"` +} + +// SessionLinks 是一路会话头上的 Issue/PR。 +type SessionLinks struct { + Issue *IssueSnap `json:"issue,omitempty"` + Pulls []PullSnap `json:"pulls"` +} + +// SessionView 是看板/侧栏用的会话摘要,不带对话正文。 +type SessionView struct { + Engine string `json:"engine"` // 对外:agent / claude / codex + SessionID string `json:"session_id"` + Summary string `json:"summary"` + Checkout string `json:"checkout"` + Branch string `json:"branch,omitempty"` + Dirty bool `json:"dirty,omitempty"` + Running bool `json:"running"` + Pending int `json:"pending"` + UpdatedAt string `json:"updated_at"` +} + +// DirView 是目录现问 Git 的状态。 +type DirView struct { + Path string `json:"path"` + Kind string `json:"kind"` + Info Info `json:"info"` + Branch string `json:"branch"` + Dirty bool `json:"dirty"` + SharedWriters []string `json:"shared_writers"` +} + +// Card 是一张 Work 在看板上的聚合。 +type Card struct { + Work Work `json:"work"` + Info Info `json:"info"` + Dirs []DirView `json:"dirs"` + Sessions []SessionView `json:"sessions"` + Running int `json:"running"` + Pending int `json:"pending"` +} + +// BoardView 是横向看板:已归组的卡 + 未归组列。 +type BoardView struct { + Cards []Card `json:"cards"` + Ungrouped []SessionView `json:"ungrouped"` +} + +// InboxItem 是一张卡下的一条待批;payload 保持各引擎原结构。 +type InboxItem struct { + Engine string `json:"engine"` + SessionID string `json:"session_id"` + TicketID string `json:"ticket_id"` + Summary string `json:"summary"` + Payload json.RawMessage `json:"payload"` +} + +// Packet 是开回合时装给模型的只读前缀材料。 +type Packet struct { + WorkInfo Info `json:"work_info"` + DirInfo *Info `json:"dir_info,omitempty"` + Issue *IssueSnap `json:"issue,omitempty"` + Pulls []PullSnap `json:"pulls"` + Text string `json:"text"` +} + +// StartSpec 描述从某张卡开一路新会话。 +type StartSpec struct { + Engine Engine + UserID string + TenantID string + AgentID string + WorkspaceID string // 新建时要冻进会话的目录;问答可空,事后再绑。 + Talk bool +} + +// Started 是新建会话后回给 Placement 的身份。 +type Started struct { + ID string + Summary string + UpdatedAt string +} + +// SessionMeta 是三引擎会话的看板摘要。 +type SessionMeta struct { + ID string + Summary string + UpdatedAt string + Running bool + Pending int + Archived bool +} + +// DecideAnswer 把 Inbox 裁决转给已有引擎,不统一三套问票。 +type DecideAnswer struct { + Native *NativeDecision `json:"native,omitempty"` + Claude *AskDecision `json:"claude,omitempty"` + Codex *AskDecision `json:"codex,omitempty"` +} + +// NativeDecision 是自有 Agent 的审批提交。 +type NativeDecision struct { + Decisions []ToolDecision `json:"decisions"` + Status string `json:"status"` + Scope string `json:"scope"` + ActorID string `json:"actor_id"` + Reason string `json:"reason"` + Override string `json:"override,omitempty"` +} + +// ToolDecision 是一条工具的批/拒。 +type ToolDecision struct { + ToolCallID string `json:"tool_call_id"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +// AskDecision 是 Claude / Codex 原问票作答。 +type AskDecision struct { + Approved bool `json:"approved"` + Scope string `json:"scope"` + Choice string `json:"choice"` + Values []string `json:"values"` + Answers map[string]string `json:"answers,omitempty"` +} + +// ParseEngine 把 HTTP / 前端引擎名收到库内值;agent 与 native 同义。 +func ParseEngine(raw string) (Engine, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "agent", "native": + return EngineNative, nil + case "claude": + return EngineClaude, nil + case "codex": + return EngineCodex, nil + default: + return "", cderr.Invalid("unknown engine %q", raw) + } +} + +// PublicEngine 把库内引擎名写成前端用的 agent / claude / codex。 +func PublicEngine(engine Engine) string { + if engine == EngineNative { + return "agent" + } + return string(engine) +} + +// ParseCheckoutKind 校验目录种类。 +func ParseCheckoutKind(raw string) (CheckoutKind, error) { + switch CheckoutKind(strings.TrimSpace(raw)) { + case CheckoutFolder, CheckoutPrimary, CheckoutWorktree: + return CheckoutKind(strings.TrimSpace(raw)), nil + case "": + return CheckoutFolder, nil + default: + return "", cderr.Invalid("unknown checkout kind %q", raw) + } +} diff --git a/server/internal/codex/session.go b/server/internal/codex/session.go index 918fd80..cd9f9e5 100644 --- a/server/internal/codex/session.go +++ b/server/internal/codex/session.go @@ -29,7 +29,10 @@ func (rt *Runtime) ListSessions(ctx context.Context, archived bool, cursor strin } st.archived = archived st.mu.Unlock() - out.Sessions = append(out.Sessions, pkg.MapThread(th, archived, active)) + sess := pkg.MapThread(th, archived, active) + running := active != "" && len(rt.PendingAsks(th.ID)) == 0 + sess.Running = &running + out.Sessions = append(out.Sessions, sess) } out.Sessions = dedupeSessions(out.Sessions) return out, nil @@ -152,6 +155,17 @@ func (rt *Runtime) sessionFromThread(sessionID string, th pkg.ThreadObject) pkg. return pkg.MapThread(th, archived, active) } +// SetCwd 记下这条对话接下来使用的工作目录。 +func (rt *Runtime) SetCwd(sessionID, cwd string) { + if rt == nil || sessionID == "" || cwd == "" { + return + } + st := rt.state(sessionID) + st.mu.Lock() + st.settings.Cwd = cwd + st.mu.Unlock() +} + // Rename 改标题。 func (rt *Runtime) Rename(ctx context.Context, sessionID, title string) error { if title == "" { diff --git a/server/internal/handler/api.go b/server/internal/handler/api.go index bcbfaa9..4ca19df 100644 --- a/server/internal/handler/api.go +++ b/server/internal/handler/api.go @@ -10,6 +10,8 @@ import ( "strings" "codedock/internal/agent" + "codedock/internal/board" + intcodex "codedock/internal/codex" "codedock/internal/config" cderr "codedock/internal/errors" "codedock/internal/events" @@ -28,6 +30,8 @@ type API struct { defaults pkgagent.RunConfigSnapshot cfg config.Config log *slog.Logger + board *board.Service + codex *intcodex.Runtime } // New 创建 Handler 入口。log 为 nil 时回退到 slog.Default。 @@ -35,7 +39,26 @@ func New(client db.Client, queries *sqlite.Queries, runtime *agent.Runtime, bus if log == nil { log = slog.Default() } - return &API{db: client, queries: queries, runtime: runtime, bus: bus, defaults: defaults, cfg: cfg, log: log} + api := &API{db: client, queries: queries, runtime: runtime, bus: bus, defaults: defaults, cfg: cfg, log: log} + api.refreshBoard() + return api +} + +// SetCodex 注入本机 Codex 运行时,供看板 Inbox / StartInDir 转给已有裁决。 +func (a *API) SetCodex(rt *intcodex.Runtime) { + if a == nil { + return + } + a.codex = rt + a.refreshBoard() +} + +// refreshBoard 按当前依赖重装看板服务。 +func (a *API) refreshBoard() { + if a == nil { + return + } + a.board = board.New(a.queries, a.boardPorts()) } // logger 返回 Handler 日志;API 或字段为空时回退到 slog.Default。 diff --git a/server/internal/handler/board.go b/server/internal/handler/board.go new file mode 100644 index 0000000..2203310 --- /dev/null +++ b/server/internal/handler/board.go @@ -0,0 +1,704 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "codedock/internal/board" + cderr "codedock/internal/errors" + "codedock/pkg/github" +) + +type CreateWorkRequest struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Title string `json:"title"` +} + +type UpdateWorkRequest struct { + Title string `json:"title"` +} + +type WorkResponse struct { + Work board.Work `json:"work"` +} + +type ListWorksResponse struct { + Works []board.Work `json:"works"` +} + +type AttachCheckoutRequest struct { + Path string `json:"path"` + Kind string `json:"kind"` +} + +type BindDirectoryRequest struct { + Path string `json:"path"` +} + +type DirectoryResponse struct { + Path string `json:"path"` +} + +type CheckoutResponse struct { + Checkout board.Checkout `json:"checkout"` +} + +type ListCheckoutsResponse struct { + Checkouts []board.Checkout `json:"checkouts"` +} + +type PutInfoRequest struct { + Checkout string `json:"checkout"` + Body string `json:"body"` +} + +type InfoResponse struct { + Info board.Info `json:"info"` +} + +type StartWorkSessionRequest struct { + Engine string `json:"engine"` + Kind string `json:"kind"` // talk | dir + Checkout string `json:"checkout"` + UserID string `json:"user_id"` + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` +} + +type PlacementResponse struct { + Placement board.Placement `json:"placement"` + SessionID string `json:"session_id"` + Engine string `json:"engine"` +} + +type AttachPlacementRequest struct { + Engine string `json:"engine"` + SessionID string `json:"session_id"` +} + +type ListPlacementsResponse struct { + Placements []board.Placement `json:"placements"` +} + +type BoardResponse struct { + Board board.BoardView `json:"board"` +} + +type CardResponse struct { + Card board.Card `json:"card"` +} + +type InboxResponse struct { + Items []board.InboxItem `json:"items"` +} + +type InboxDecideRequest struct { + Engine string `json:"engine"` + TicketID string `json:"ticket_id"` + Decisions []board.ToolDecision `json:"decisions"` + Status string `json:"status"` + Scope string `json:"scope"` + ActorID string `json:"actor_id"` + Reason string `json:"reason"` + Override string `json:"override,omitempty"` + Approved bool `json:"approved"` + Choice string `json:"choice"` + Values []string `json:"values"` + Answers map[string]string `json:"answers,omitempty"` +} + +type PacketResponse struct { + Packet board.Packet `json:"packet"` +} + +type LinksResponse struct { + Links board.SessionLinks `json:"links"` +} + +type PutLinkRequest struct { + Repo string `json:"repo"` + Number int `json:"number"` + Ref string `json:"ref"` +} + +type ReplaceLinksRequest struct { + Links []string `json:"links"` +} + +// CreateWork 建一张卡并挂空白 Work 级 Info。 +func (a *API) CreateWork(w http.ResponseWriter, r *http.Request) { + var req CreateWorkRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + work, err := board.CreateWork(r.Context(), a.q(r.Context()), req.TenantID, req.UserID, req.Title) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, WorkResponse{Work: work}) +} + +// ListWorks 列出用户的卡。 +func (a *API) ListWorks(w http.ResponseWriter, r *http.Request) { + works, err := board.ListWorks(r.Context(), a.q(r.Context()), r.URL.Query().Get("tenant_id"), r.URL.Query().Get("user_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ListWorksResponse{Works: works}) +} + +// GetWork 读取一张卡。 +func (a *API) GetWork(w http.ResponseWriter, r *http.Request) { + work, err := board.GetWork(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, WorkResponse{Work: work}) +} + +// UpdateWork 改卡片标题。 +func (a *API) UpdateWork(w http.ResponseWriter, r *http.Request) { + var req UpdateWorkRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + work, err := board.UpdateWork(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id"), req.Title) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, WorkResponse{Work: work}) +} + +// DeleteWork 删卡并断开归属。 +func (a *API) DeleteWork(w http.ResponseWriter, r *http.Request) { + if err := board.DeleteWork(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// AttachWorkCheckout 把已存在目录挂到卡上。 +func (a *API) AttachWorkCheckout(w http.ResponseWriter, r *http.Request) { + var req AttachCheckoutRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + kind, err := board.ParseCheckoutKind(req.Kind) + if err != nil { + writeError(w, err) + return + } + co, err := board.AttachCheckout(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id"), req.Path, kind) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, CheckoutResponse{Checkout: co}) +} + +// ListWorkCheckouts 列出卡上目录。 +func (a *API) ListWorkCheckouts(w http.ResponseWriter, r *http.Request) { + items, err := board.ListCheckouts(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ListCheckoutsResponse{Checkouts: items}) +} + +// DetachWorkCheckout 卸下目录,该路径上的会话变成问答。 +func (a *API) DetachWorkCheckout(w http.ResponseWriter, r *http.Request) { + var req AttachCheckoutRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + if req.Path == "" { + req.Path = r.URL.Query().Get("path") + } + if err := board.DetachCheckout(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id"), req.Path); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// GetWorkInfo 读 Work 级或目录级说明。 +func (a *API) GetWorkInfo(w http.ResponseWriter, r *http.Request) { + info, err := board.GetInfo(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id"), r.URL.Query().Get("checkout")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, InfoResponse{Info: info}) +} + +// PutWorkInfo 覆盖写入说明。 +func (a *API) PutWorkInfo(w http.ResponseWriter, r *http.Request) { + var req PutInfoRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + info, err := board.PutInfo(r.Context(), a.q(r.Context()), chi.URLParam(r, "work_id"), req.Checkout, req.Body) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, InfoResponse{Info: info}) +} + +// StartWorkSession 从一张卡开问答或目录会话。 +func (a *API) StartWorkSession(w http.ResponseWriter, r *http.Request) { + var req StartWorkSessionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + engine, err := board.ParseEngine(req.Engine) + if err != nil { + writeError(w, err) + return + } + spec := board.StartSpec{Engine: engine, UserID: req.UserID, TenantID: req.TenantID, AgentID: req.AgentID} + kind := strings.ToLower(strings.TrimSpace(req.Kind)) + var place board.Placement + var started board.Started + if kind == "dir" { + place, started, err = a.board.StartInDir(r.Context(), chi.URLParam(r, "work_id"), req.Checkout, spec) + } else { + place, started, err = a.board.StartTalk(r.Context(), chi.URLParam(r, "work_id"), spec) + } + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, PlacementResponse{ + Placement: publicPlacement(place), + SessionID: started.ID, + Engine: board.PublicEngine(place.Engine), + }) +} + +// AttachWorkPlacement 把未归组会话补挂到卡上。 +func (a *API) AttachWorkPlacement(w http.ResponseWriter, r *http.Request) { + var req AttachPlacementRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + engine, err := board.ParseEngine(req.Engine) + if err != nil { + writeError(w, err) + return + } + place, err := board.AttachExisting(r.Context(), a.q(r.Context()), engine, req.SessionID, chi.URLParam(r, "work_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, PlacementResponse{ + Placement: publicPlacement(place), + SessionID: place.SessionID, + Engine: board.PublicEngine(place.Engine), + }) +} + +// ListPlacements 列出用户所有归属,给侧栏分组。 +func (a *API) ListPlacements(w http.ResponseWriter, r *http.Request) { + items, err := board.ListUserPlacements(r.Context(), a.q(r.Context()), r.URL.Query().Get("tenant_id"), r.URL.Query().Get("user_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ListPlacementsResponse{Placements: publicPlacements(items)}) +} + +// GetPlacement 读一路会话的归属。 +func (a *API) GetPlacement(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + place, err := board.GetPlacement(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, PlacementResponse{ + Placement: publicPlacement(place), + SessionID: place.SessionID, + Engine: board.PublicEngine(place.Engine), + }) +} + +// BindSessionDirectory 在创建会话时记下目录。不能解绑。 +func (a *API) BindSessionDirectory(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + if r.Method == http.MethodDelete { + writeError(w, cderr.Invalid("directory cannot be unbound")) + return + } + path := "" + if r.Body != nil { + var req BindDirectoryRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + path = req.Path + } + bound, err := a.board.BindSessionDirectory(r.Context(), engine, chi.URLParam(r, "session_id"), path) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, DirectoryResponse{Path: bound}) +} + +// DeletePlacement 断开归属。 +func (a *API) DeletePlacement(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + if err := board.Unbind(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// GetBoard 聚合横向看板。 +func (a *API) GetBoard(w http.ResponseWriter, r *http.Request) { + view, err := a.board.Cards(r.Context(), r.URL.Query().Get("tenant_id"), r.URL.Query().Get("user_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, BoardResponse{Board: view}) +} + +// GetBoardCard 聚合一张卡。 +func (a *API) GetBoardCard(w http.ResponseWriter, r *http.Request) { + card, err := a.board.Card(r.Context(), chi.URLParam(r, "work_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, CardResponse{Card: card}) +} + +// ListWorkInbox 扫该卡下三引擎待批。 +func (a *API) ListWorkInbox(w http.ResponseWriter, r *http.Request) { + items, err := a.board.ListInbox(r.Context(), chi.URLParam(r, "work_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, InboxResponse{Items: items}) +} + +// DecideInbox 按引擎把裁决转给已有审批。 +func (a *API) DecideInbox(w http.ResponseWriter, r *http.Request) { + var req InboxDecideRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + engine, err := board.ParseEngine(req.Engine) + if err != nil { + writeError(w, err) + return + } + answer := board.DecideAnswer{} + switch engine { + case board.EngineNative: + answer.Native = &board.NativeDecision{ + Decisions: req.Decisions, Status: req.Status, Scope: req.Scope, + ActorID: req.ActorID, Reason: req.Reason, Override: req.Override, + } + case board.EngineClaude: + answer.Claude = &board.AskDecision{Approved: req.Approved, Scope: req.Scope, Choice: req.Choice, Values: req.Values, Answers: req.Answers} + case board.EngineCodex: + answer.Codex = &board.AskDecision{Approved: req.Approved, Scope: req.Scope, Choice: req.Choice, Values: req.Values, Answers: req.Answers} + } + if err := a.board.DecideInbox(r.Context(), engine, req.TicketID, answer); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// GetPlacementPacket 读开回合用的只读 Packet。 +func (a *API) GetPlacementPacket(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + pkt, err := board.BuildPacket(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, PacketResponse{Packet: pkt}) +} + +// GetSessionLinks 读会话头上的 Issue/PR。 +func (a *API) GetSessionLinks(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + links, err := board.GetLinks(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, LinksResponse{Links: links}) +} + +// ReplaceSessionLinks 用一批链接收掉会话上的 Issue/PR。种类从链接路径判断,调用方不用分开填。 +func (a *API) ReplaceSessionLinks(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + var req ReplaceLinksRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + sessionID := chi.URLParam(r, "session_id") + var issue *board.IssueSnap + pulls := []board.PullSnap{} + seen := map[string]bool{} + for _, raw := range req.Links { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + repo, number, kind, err := github.ParseLink(raw) + if err != nil { + writeError(w, cderr.Invalid("%s", err.Error())) + return + } + key := kind + ":" + repo + "#" + strconv.Itoa(number) + if seen[key] { + continue + } + seen[key] = true + switch kind { + case "pull": + snap, err := a.pullSnap(engine, sessionID, repo, number, raw) + if err != nil { + writeError(w, err) + return + } + pulls = append(pulls, snap) + case "issue": + if issue != nil { + writeError(w, cderr.Invalid("一个会话只能挂一条 Issue 链接")) + return + } + snap, err := a.issueSnap(engine, sessionID, repo, number, raw) + if err != nil { + writeError(w, err) + return + } + issue = &snap + default: + snap, pullErr := a.pullSnap(engine, sessionID, repo, number, raw) + if pullErr == nil { + pulls = append(pulls, snap) + continue + } + if issue != nil { + writeError(w, cderr.Invalid("一个会话只能挂一条 Issue 链接")) + return + } + saved, issueErr := a.issueSnap(engine, sessionID, repo, number, raw) + if issueErr != nil { + writeError(w, pullErr) + return + } + issue = &saved + } + } + links, err := board.ReplaceLinks(r.Context(), a.q(r.Context()), engine, sessionID, issue, pulls) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, LinksResponse{Links: links}) +} + +// issueSnap 拉一条 Issue,缺链接时回填用户输入。 +func (a *API) issueSnap(engine board.Engine, sessionID, repo string, number int, raw string) (board.IssueSnap, error) { + snap, err := viewBoardIssue(repo, number) + if err != nil { + return board.IssueSnap{}, cderr.Unavailable("%s", err.Error()) + } + if snap.URL == "" { + snap.URL = raw + } + snap.Engine = engine + snap.SessionID = sessionID + return snap, nil +} + +// pullSnap 拉一条 PR,缺链接时回填用户输入。 +func (a *API) pullSnap(engine board.Engine, sessionID, repo string, number int, raw string) (board.PullSnap, error) { + snap, err := viewBoardPull(repo, number) + if err != nil { + return board.PullSnap{}, cderr.Unavailable("%s", err.Error()) + } + if snap.URL == "" { + snap.URL = raw + } + snap.Engine = engine + snap.SessionID = sessionID + return snap, nil +} + +// PutSessionIssue 用 gh 拉 Issue 快照并挂到会话头。 +func (a *API) PutSessionIssue(w http.ResponseWriter, r *http.Request) { + engine, sessionID, repo, number, err := a.parseLink(r) + if err != nil { + writeError(w, err) + return + } + snap, err := viewBoardIssue(repo, number) + if err != nil { + writeError(w, cderr.Unavailable("%s", err.Error())) + return + } + snap.Engine = engine + snap.SessionID = sessionID + saved, err := board.PutIssue(r.Context(), a.q(r.Context()), snap) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"issue": saved}) +} + +// DeleteSessionIssue 去掉会话上的 Issue。 +func (a *API) DeleteSessionIssue(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + if err := board.DeleteIssue(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// PutSessionPull 用 gh 拉 PR 快照并挂到会话头。 +func (a *API) PutSessionPull(w http.ResponseWriter, r *http.Request) { + engine, sessionID, repo, number, err := a.parseLink(r) + if err != nil { + writeError(w, err) + return + } + snap, err := viewBoardPull(repo, number) + if err != nil { + writeError(w, cderr.Unavailable("%s", err.Error())) + return + } + snap.Engine = engine + snap.SessionID = sessionID + saved, err := board.PutPull(r.Context(), a.q(r.Context()), snap) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"pull": saved}) +} + +// DeleteSessionPull 去掉会话上的一条 PR。 +func (a *API) DeleteSessionPull(w http.ResponseWriter, r *http.Request) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + writeError(w, err) + return + } + number, err := strconv.Atoi(chi.URLParam(r, "number")) + if err != nil { + writeError(w, cderr.Invalid("invalid pull number")) + return + } + if err := board.DeletePull(r.Context(), a.q(r.Context()), engine, chi.URLParam(r, "session_id"), r.URL.Query().Get("repo"), number); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// parseLink 从路径与 body 收出发动机、会话和 Issue/PR 编号。 +func (a *API) parseLink(r *http.Request) (board.Engine, string, string, int, error) { + engine, err := board.ParseEngine(chi.URLParam(r, "engine")) + if err != nil { + return "", "", "", 0, err + } + var req PutLinkRequest + if err := decodeJSON(r, &req); err != nil { + return "", "", "", 0, err + } + repo, number := req.Repo, req.Number + if req.Ref != "" { + parsedRepo, parsedN, refErr := github.FormatRef(req.Ref) + if refErr != nil { + return "", "", "", 0, cderr.Invalid("%s", refErr.Error()) + } + if repo == "" { + repo = parsedRepo + } + if number == 0 { + number = parsedN + } + } + if number <= 0 { + return "", "", "", 0, cderr.Invalid("issue or pull number is required") + } + return engine, chi.URLParam(r, "session_id"), repo, number, nil +} + +// publicPlacement 把库内 native 写成前端 agent。 +func publicPlacement(place board.Placement) board.Placement { + place.Engine = board.Engine(board.PublicEngine(place.Engine)) + return place +} + +// publicPlacements 批量写成前端引擎名。 +func publicPlacements(items []board.Placement) []board.Placement { + out := make([]board.Placement, 0, len(items)) + for _, item := range items { + out = append(out, publicPlacement(item)) + } + return out +} diff --git a/server/internal/handler/board_ports.go b/server/internal/handler/board_ports.go new file mode 100644 index 0000000..7d9ca0e --- /dev/null +++ b/server/internal/handler/board_ports.go @@ -0,0 +1,408 @@ +package handler + +import ( + "context" + "encoding/json" + "time" + + "codedock/internal/board" + cderr "codedock/internal/errors" + "codedock/internal/util" + pkgagent "codedock/pkg/agent" + "codedock/pkg/claude" + pkgcodex "codedock/pkg/codex" + "codedock/pkg/db/sqlite" + "codedock/pkg/git" + "codedock/pkg/github" +) + +// boardPorts 把建会话、Git、gh、三引擎问票接到看板服务。 +func (a *API) boardPorts() board.Ports { + return board.Ports{ + StartNative: a.startNativeBoard, + StartClaude: a.startClaudeBoard, + StartCodex: a.startCodexBoard, + Lookup: a.lookupBoardSession, + ListEngine: a.listBoardEngine, + InspectDir: inspectBoardDir, + ViewIssue: viewBoardIssue, + ViewPull: viewBoardPull, + ListAsks: a.listBoardAsks, + Decide: a.decideBoardAsk, + ApplyDir: a.applyBoardDir, + } +} + +// applyBoardDir 把会话目录写进对应引擎的工作目录。 +func (a *API) applyBoardDir(ctx context.Context, engine board.Engine, sessionID, path string) error { + if path == "" { + return nil + } + switch engine { + case board.EngineNative: + if a == nil || a.q(ctx) == nil { + return cderr.Unavailable("database is required") + } + return a.q(ctx).SetSessionWorkspace(ctx, sqlite.SetSessionWorkspaceParams{ + WorkspaceID: path, + UpdatedAt: util.FormatTime(util.Now()), + ID: sessionID, + }) + case board.EngineClaude: + _, err := claude.Apply(sessionID, claude.Settings{Cwd: path, Overridden: []string{"cwd"}}) + return err + case board.EngineCodex: + if a == nil || a.codex == nil { + return cderr.Unavailable("codex is not configured") + } + a.codex.SetCwd(sessionID, path) + return nil + default: + return cderr.Invalid("unknown engine") + } +} + +// startNativeBoard 走现有 CreateSession:冻 workspace_id。 +func (a *API) startNativeBoard(ctx context.Context, spec board.StartSpec) (board.Started, error) { + if a == nil || a.q(ctx) == nil { + return board.Started{}, cderr.Unavailable("database is required") + } + if spec.UserID == "" { + return board.Started{}, cderr.Invalid("user_id is required") + } + if spec.TenantID == "" { + spec.TenantID = "default" + } + if spec.AgentID == "" { + spec.AgentID = "default" + } + root, err := freezeSessionWorkspace(spec.WorkspaceID, a.cfg.DefaultRoot()) + if err != nil { + return board.Started{}, err + } + now := util.FormatTime(util.Now()) + row, err := a.q(ctx).InsertSession(ctx, sqlite.InsertSessionParams{ + ID: util.NewID(), + TenantID: spec.TenantID, + UserID: spec.UserID, + AgentID: spec.AgentID, + WorkspaceID: root, + Status: string(pkgagent.SessionActive), + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return board.Started{}, err + } + return board.Started{ID: row.ID, UpdatedAt: row.UpdatedAt}, nil +} + +// startClaudeBoard 走现有 Claude 建会话,目录会话再记 cwd。 +func (a *API) startClaudeBoard(_ context.Context, spec board.StartSpec) (board.Started, error) { + sess, err := claude.Create(spec.UserID) + if err != nil { + return board.Started{}, err + } + if spec.WorkspaceID != "" { + if _, applyErr := claude.Apply(sess.ID, claude.Settings{Cwd: spec.WorkspaceID, Overridden: []string{"cwd"}}); applyErr != nil { + a.logger().Info("claude cwd apply skipped", "session_id", sess.ID, "error", applyErr) + } + } + return board.Started{ + ID: sess.ID, + Summary: sess.Title, + UpdatedAt: stampRFC(sess.UpdatedAt), + }, nil +} + +// startCodexBoard 走现有 Codex 建会话。 +func (a *API) startCodexBoard(ctx context.Context, spec board.StartSpec) (board.Started, error) { + if a == nil || a.codex == nil { + return board.Started{}, cderr.Unavailable("codex is not configured") + } + sess, err := a.codex.CreateSession(ctx, pkgcodex.Settings{Cwd: spec.WorkspaceID}) + if err != nil { + return board.Started{}, err + } + return board.Started{ + ID: sess.ID, + Summary: firstNonEmpty(sess.Title, sess.Preview), + UpdatedAt: stampRFC(sess.UpdatedAt), + }, nil +} + +// nativeSessionLive 只有 Worker 还在执行的 active Run 才算进行中。等审批、已结束、需要恢复都不算。 +func (a *API) nativeSessionLive(ctx context.Context, activeRunID string) bool { + if a == nil || activeRunID == "" { + return false + } + if a.runNeedsRecover(ctx, activeRunID) { + return false + } + row, err := a.q(ctx).GetRun(ctx, activeRunID) + if err != nil { + return false + } + return pkgagent.IsExecuting(pkgagent.RunStatus(row.Status)) +} + +// lookupBoardSession 读三引擎会话摘要。 +func (a *API) lookupBoardSession(ctx context.Context, engine board.Engine, sessionID string) (board.SessionMeta, error) { + switch engine { + case board.EngineNative: + row, err := a.q(ctx).GetSession(ctx, sessionID) + if err != nil { + return board.SessionMeta{}, wrapHandlerDB(err) + } + pending, _ := a.q(ctx).CountPendingApprovals(ctx, sessionID) + active := "" + if row.ActiveRunID.Valid { + active = row.ActiveRunID.String + } + return board.SessionMeta{ + ID: row.ID, + Summary: row.Summary, + UpdatedAt: row.UpdatedAt, + Running: a.nativeSessionLive(ctx, active), + Pending: int(pending), + Archived: row.Status == string(pkgagent.SessionArchived), + }, nil + case board.EngineClaude: + sess, err := claude.Get(sessionID) + if err != nil { + return board.SessionMeta{}, err + } + pending := len(claude.PendingAsks(sess.ID)) + return board.SessionMeta{ + ID: sess.ID, + Summary: firstNonEmpty(sess.Title, sess.ClaudeSessionID), + UpdatedAt: stampRFC(sess.UpdatedAt), + Running: sess.ActiveTurnID != "" && pending == 0, + Pending: pending, + Archived: sess.Archived, + }, nil + case board.EngineCodex: + if a.codex == nil { + return board.SessionMeta{}, cderr.Unavailable("codex is not configured") + } + sess, _, err := a.codex.GetSession(ctx, sessionID) + if err != nil { + return board.SessionMeta{}, err + } + pending := len(a.codex.PendingAsks(sessionID)) + return board.SessionMeta{ + ID: sess.ID, + Summary: firstNonEmpty(sess.Title, sess.Preview), + UpdatedAt: stampRFC(sess.UpdatedAt), + Running: sess.ActiveTurnID != "" && pending == 0, + Pending: pending, + Archived: sess.Archived, + }, nil + default: + return board.SessionMeta{}, cderr.Invalid("unknown engine") + } +} + +// listBoardEngine 列 Claude / Codex 会话给未归组列。 +func (a *API) listBoardEngine(ctx context.Context, engine board.Engine) ([]board.SessionMeta, error) { + switch engine { + case board.EngineClaude: + sessions, err := claude.ListSessions() + if err != nil { + return nil, err + } + out := make([]board.SessionMeta, 0, len(sessions)) + for _, sess := range sessions { + pending := len(claude.PendingAsks(sess.ID)) + out = append(out, board.SessionMeta{ + ID: sess.ID, + Summary: firstNonEmpty(sess.Title, sess.ClaudeSessionID), + UpdatedAt: stampRFC(sess.UpdatedAt), + Running: sess.ActiveTurnID != "" && pending == 0, + Pending: pending, + Archived: sess.Archived, + }) + } + return out, nil + case board.EngineCodex: + if a.codex == nil { + return nil, nil + } + page, err := a.codex.ListSessions(ctx, false, "") + if err != nil { + return nil, err + } + out := make([]board.SessionMeta, 0, len(page.Sessions)) + for _, sess := range page.Sessions { + pending := len(a.codex.PendingAsks(sess.ID)) + out = append(out, board.SessionMeta{ + ID: sess.ID, + Summary: firstNonEmpty(sess.Title, sess.Preview), + UpdatedAt: stampRFC(sess.UpdatedAt), + Running: sess.ActiveTurnID != "" && pending == 0, + Pending: pending, + Archived: sess.Archived, + }) + } + return out, nil + default: + return nil, nil + } +} + +// listBoardAsks 列 Claude / Codex 待批,payload 保持原问票。 +func (a *API) listBoardAsks(engine board.Engine, sessionID string) []board.InboxItem { + var items []board.InboxItem + switch engine { + case board.EngineClaude: + for _, ask := range claude.PendingAsks(sessionID) { + raw, _ := json.Marshal(ask) + items = append(items, board.InboxItem{ + Engine: board.PublicEngine(engine), + SessionID: sessionID, + TicketID: ask.ExternalRequestID, + Summary: firstNonEmpty(ask.Prompt, ask.Command, string(ask.Kind)), + Payload: raw, + }) + } + case board.EngineCodex: + if a == nil || a.codex == nil { + return items + } + for _, ask := range a.codex.PendingAsks(sessionID) { + raw, _ := json.Marshal(ask) + items = append(items, board.InboxItem{ + Engine: board.PublicEngine(engine), + SessionID: sessionID, + TicketID: firstNonEmpty(ask.ID, ask.ExternalRequestID), + Summary: firstNonEmpty(ask.Prompt, ask.Command, string(ask.Kind)), + Payload: raw, + }) + } + } + return items +} + +// decideBoardAsk 按引擎转给已有裁决。 +func (a *API) decideBoardAsk(ctx context.Context, engine board.Engine, ticketID string, answer board.DecideAnswer) error { + switch engine { + case board.EngineNative: + if answer.Native == nil { + return cderr.Invalid("native decision is required") + } + req := DecideApprovalRequest{ + ApprovalID: ticketID, + Status: pkgagent.ApprovalStatus(answer.Native.Status), + Scope: pkgagent.ApprovalScope(answer.Native.Scope), + ActorID: answer.Native.ActorID, + Reason: answer.Native.Reason, + Override: pkgagent.OverrideAction(answer.Native.Override), + } + for _, item := range answer.Native.Decisions { + req.Decisions = append(req.Decisions, ToolDecision{ + ToolCallID: item.ToolCallID, + Status: pkgagent.ApprovalStatus(item.Status), + Reason: item.Reason, + }) + } + _, err := a.decide(ctx, req) + return err + case board.EngineClaude: + if answer.Claude == nil { + return cderr.Invalid("claude decision is required") + } + return claude.Decide(ticketID, claude.AskAnswer{ + Approved: answer.Claude.Approved, + Scope: claude.DecisionScope(answer.Claude.Scope), + Choice: answer.Claude.Choice, + Values: answer.Claude.Values, + }) + case board.EngineCodex: + if a == nil || a.codex == nil { + return cderr.Unavailable("codex is not configured") + } + if answer.Codex == nil { + return cderr.Invalid("codex decision is required") + } + return a.codex.Decide(ctx, ticketID, pkgcodex.AskAnswer{ + Approved: answer.Codex.Approved, + Scope: pkgcodex.DecisionScope(answer.Codex.Scope), + Choice: answer.Codex.Choice, + Values: answer.Codex.Values, + Answers: answer.Codex.Answers, + }) + default: + return cderr.Invalid("unknown engine") + } +} + +// inspectBoardDir 现问 Git:分支、dirty、同仓其他 worktree。 +func inspectBoardDir(path string) board.DirView { + view := board.DirView{Path: path, SharedWriters: []string{}} + repo, err := git.Open(path) + if err != nil { + return view + } + state, err := git.Status(repo, git.Checkout{Path: path}) + if err != nil || !state.IsRepo { + return view + } + view.Branch = state.Branch + view.Dirty = git.Dirty(state) + if writers, err := git.SharedWriters(repo, git.Checkout{Path: path}); err == nil && writers != nil { + view.SharedWriters = writers + } + return view +} + +// viewBoardIssue 用本机 gh 拉 Issue 快照。 +func viewBoardIssue(repo string, number int) (board.IssueSnap, error) { + issue, err := github.ViewIssue(context.Background(), repo, int64(number)) + if err != nil { + return board.IssueSnap{}, err + } + return board.IssueSnap{ + Repo: issue.Repo, + Number: int(issue.Number), + Title: issue.Title, + Body: issue.Body, + URL: issue.URL, + State: issue.State, + }, nil +} + +// viewBoardPull 用本机 gh 拉 PR 快照。 +func viewBoardPull(repo string, number int) (board.PullSnap, error) { + pull, err := github.ViewPull(context.Background(), repo, int64(number)) + if err != nil { + return board.PullSnap{}, err + } + return board.PullSnap{ + Repo: pull.Repo, + Number: int(pull.Number), + Title: pull.Title, + Body: pull.Body, + URL: pull.URL, + State: pull.State, + }, nil +} + +// stampRFC 把 Unix 秒收成 RFC3339。 +// stampRFC 把 Unix 秒收成 RFC3339;空值保持空串。 +func stampRFC(sec int64) string { + if sec <= 0 { + return "" + } + return time.Unix(sec, 0).UTC().Format(time.RFC3339) +} + +// firstNonEmpty 取第一个非空字符串。 +// firstNonEmpty 返回第一个非空字符串。 +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/server/internal/handler/board_test.go b/server/internal/handler/board_test.go new file mode 100644 index 0000000..0a35795 --- /dev/null +++ b/server/internal/handler/board_test.go @@ -0,0 +1,104 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "codedock/internal/handler" +) + +func TestBoardWorkHTTP(t *testing.T) { + f := newFixture(t) + dir := t.TempDir() + rec := f.do(t, http.MethodPost, "/works", handler.CreateWorkRequest{UserID: "u1", TenantID: "t1", Title: "修登录"}) + if rec.Code != http.StatusOK { + t.Fatalf("create work %d %s", rec.Code, rec.Body.String()) + } + var created handler.WorkResponse + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if created.Work.ID == "" || created.Work.Title != "修登录" { + t.Fatalf("work %+v", created.Work) + } + rec = f.do(t, http.MethodPost, "/works/"+created.Work.ID+"/checkouts", handler.AttachCheckoutRequest{Path: dir, Kind: "folder"}) + if rec.Code != http.StatusOK { + t.Fatalf("attach %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodPut, "/works/"+created.Work.ID+"/info", handler.PutInfoRequest{Body: "卡片说明"}) + if rec.Code != http.StatusOK { + t.Fatalf("info %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodPost, "/works/"+created.Work.ID+"/sessions", handler.StartWorkSessionRequest{ + Engine: "agent", Kind: "talk", UserID: "u1", TenantID: "t1", + }) + if rec.Code != http.StatusOK { + t.Fatalf("start talk %d %s", rec.Code, rec.Body.String()) + } + var talk handler.PlacementResponse + if err := json.Unmarshal(rec.Body.Bytes(), &talk); err != nil { + t.Fatal(err) + } + if talk.Engine != "agent" || talk.Placement.Checkout != "" { + t.Fatalf("talk %+v", talk) + } + rec = f.do(t, http.MethodPost, "/works/"+created.Work.ID+"/sessions", handler.StartWorkSessionRequest{ + Engine: "agent", Kind: "dir", Checkout: dir, UserID: "u1", TenantID: "t1", + }) + if rec.Code != http.StatusOK { + t.Fatalf("start dir %d %s", rec.Code, rec.Body.String()) + } + sessionID := f.createSession(t) + rec = f.do(t, http.MethodPost, "/works/"+created.Work.ID+"/placements", handler.AttachPlacementRequest{ + Engine: "agent", SessionID: sessionID, + }) + if rec.Code != http.StatusOK { + t.Fatalf("attach session %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodGet, "/board?user_id=u1&tenant_id=t1", nil) + if rec.Code != http.StatusOK { + t.Fatalf("board %d %s", rec.Code, rec.Body.String()) + } + var board handler.BoardResponse + if err := json.Unmarshal(rec.Body.Bytes(), &board); err != nil { + t.Fatal(err) + } + if len(board.Board.Cards) != 1 { + t.Fatalf("cards %+v", board.Board) + } + rec = f.do(t, http.MethodGet, "/placements/"+talk.Engine+"/"+talk.SessionID+"/packet", nil) + if rec.Code != http.StatusOK { + t.Fatalf("packet %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodGet, "/works/"+created.Work.ID+"/inbox", nil) + if rec.Code != http.StatusOK { + t.Fatalf("inbox %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodDelete, "/works/"+created.Work.ID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("delete %d %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, http.MethodGet, "/placements/agent/"+talk.SessionID, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("unbound placement %d %s", rec.Code, rec.Body.String()) + } +} + +func TestBoardAttachRequiresExistingDir(t *testing.T) { + f := newFixture(t) + rec := f.do(t, http.MethodPost, "/works", handler.CreateWorkRequest{UserID: "u1", Title: "x"}) + var created handler.WorkResponse + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + missing := filepath.Join(os.TempDir(), "codedock-missing-checkout") + rec = f.do(t, http.MethodPost, "/works/"+created.Work.ID+"/sessions", handler.StartWorkSessionRequest{ + Engine: "agent", Kind: "dir", Checkout: missing, UserID: "u1", + }) + if rec.Code == http.StatusOK { + t.Fatalf("missing checkout should fail: %s", rec.Body.String()) + } +} diff --git a/server/internal/handler/claude.go b/server/internal/handler/claude.go index 0251b8c..e4413c6 100644 --- a/server/internal/handler/claude.go +++ b/server/internal/handler/claude.go @@ -5,6 +5,7 @@ import ( "github.com/go-chi/chi/v5" + "codedock/internal/board" "codedock/pkg/claude" ) @@ -110,6 +111,7 @@ type ClaudeSession struct { ClaudeSessionID string `json:"claude_session_id"` Title string `json:"title"` ActiveTurnID string `json:"active_turn_id"` + Running *bool `json:"running,omitempty"` // 有进行中的回合且没有待审批。空表示没带。 Archived bool `json:"archived"` CreatedAt int64 `json:"created_at"` // 本机实录第一条时间,Unix 秒。 UpdatedAt int64 `json:"updated_at"` // 本机实录最近一条时间,Unix 秒。 @@ -380,7 +382,11 @@ func (a *API) ClaudeStartTurn(w http.ResponseWriter, r *http.Request) { if req.Mode == string(claude.InputModeQueue) { mode = claude.InputModeQueue } - turnID, err := claude.Start(req.SessionID, req.Content, claude.Input{ + content := req.Content + if pkt, err := board.BuildPacket(r.Context(), a.q(r.Context()), board.EngineClaude, req.SessionID); err == nil { + content = board.PrefixContent(pkt.Text, content) + } + turnID, err := claude.Start(req.SessionID, content, claude.Input{ Text: req.Input.Text, Mentions: claudeStrings(req.Input.Mentions), Images: claudeStrings(req.Input.Images), @@ -473,12 +479,15 @@ func claudeStrings(values []string) []string { return values } +// mapClaudeSession 把本机 Claude 会话收成 HTTP 形状。有待审批时 running 为 false。 func mapClaudeSession(sess claude.Session) ClaudeSession { + running := sess.ActiveTurnID != "" && len(claude.PendingAsks(sess.ID)) == 0 return ClaudeSession{ ID: sess.ID, ClaudeSessionID: sess.ClaudeSessionID, Title: sess.Title, ActiveTurnID: sess.ActiveTurnID, + Running: &running, Archived: sess.Archived, CreatedAt: sess.CreatedAt, UpdatedAt: sess.UpdatedAt, diff --git a/server/internal/handler/codex/api.go b/server/internal/handler/codex/api.go index a7967f6..7fb3c61 100644 --- a/server/internal/handler/codex/api.go +++ b/server/internal/handler/codex/api.go @@ -1,6 +1,7 @@ package codex import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" + "codedock/internal/board" intcodex "codedock/internal/codex" cderr "codedock/internal/errors" pkg "codedock/pkg/codex" @@ -15,7 +17,9 @@ import ( // API 是 /codex HTTP 薄桥接。 type API struct { - rt *intcodex.Runtime + rt *intcodex.Runtime + packet func(ctx context.Context, sessionID string) string + directory func(ctx context.Context, sessionID string) string } // New 构造 Codex HTTP 入口。 @@ -23,6 +27,22 @@ func New(rt *intcodex.Runtime) *API { return &API{rt: rt} } +// SetPacket 注入 Work Packet 只读前缀读取函数。 +func (a *API) SetPacket(fn func(ctx context.Context, sessionID string) string) { + if a == nil { + return + } + a.packet = fn +} + +// SetDirectory 注入会话目录读取函数,开回合前写回 Codex 工作目录。 +func (a *API) SetDirectory(fn func(ctx context.Context, sessionID string) string) { + if a == nil { + return + } + a.directory = fn +} + // Mount 把 /codex 路由挂到父路由器上。 func (a *API) Mount(r chi.Router) { if a == nil || a.rt == nil { @@ -271,7 +291,17 @@ func (a *API) StartTurn(w http.ResponseWriter, r *http.Request) { if mode == "" { mode = pkg.InputStart } - turn, err := a.rt.StartTurn(r.Context(), chi.URLParam(r, "id"), req.Content, req.Input, mode) + sessionID := chi.URLParam(r, "id") + if a.directory != nil { + if path := a.directory(r.Context(), sessionID); path != "" { + a.rt.SetCwd(sessionID, path) + } + } + content := req.Content + if a.packet != nil { + content = board.PrefixContent(a.packet(r.Context(), sessionID), content) + } + turn, err := a.rt.StartTurn(r.Context(), sessionID, content, req.Input, mode) if err != nil { writeError(w, err) return diff --git a/server/internal/handler/fixture_test.go b/server/internal/handler/fixture_test.go index f6c79f4..85bf966 100644 --- a/server/internal/handler/fixture_test.go +++ b/server/internal/handler/fixture_test.go @@ -149,6 +149,34 @@ func testRouter(api *handler.API) http.Handler { r.Get("/memories", api.ListTextMemories) r.Get("/memories/{scope}/{scope_id}", api.GetTextMemory) r.Delete("/memories/{scope}/{scope_id}", api.DeleteTextMemory) + r.Post("/works", api.CreateWork) + r.Get("/works", api.ListWorks) + r.Get("/works/{work_id}", api.GetWork) + r.Patch("/works/{work_id}", api.UpdateWork) + r.Delete("/works/{work_id}", api.DeleteWork) + r.Post("/works/{work_id}/checkouts", api.AttachWorkCheckout) + r.Get("/works/{work_id}/checkouts", api.ListWorkCheckouts) + r.Delete("/works/{work_id}/checkouts", api.DetachWorkCheckout) + r.Get("/works/{work_id}/info", api.GetWorkInfo) + r.Put("/works/{work_id}/info", api.PutWorkInfo) + r.Post("/works/{work_id}/sessions", api.StartWorkSession) + r.Post("/works/{work_id}/placements", api.AttachWorkPlacement) + r.Get("/works/{work_id}/inbox", api.ListWorkInbox) + r.Get("/board", api.GetBoard) + r.Get("/board/cards/{work_id}", api.GetBoardCard) + r.Get("/placements", api.ListPlacements) + r.Get("/placements/{engine}/{session_id}", api.GetPlacement) + r.Delete("/placements/{engine}/{session_id}", api.DeletePlacement) + r.Get("/placements/{engine}/{session_id}/packet", api.GetPlacementPacket) + r.Post("/inbox/decision", api.DecideInbox) + r.Put("/session-directories/{engine}/{session_id}", api.BindSessionDirectory) + r.Delete("/session-directories/{engine}/{session_id}", api.BindSessionDirectory) + r.Get("/session-links/{engine}/{session_id}", api.GetSessionLinks) + r.Put("/session-links/{engine}/{session_id}", api.ReplaceSessionLinks) + r.Put("/session-links/{engine}/{session_id}/issue", api.PutSessionIssue) + r.Delete("/session-links/{engine}/{session_id}/issue", api.DeleteSessionIssue) + r.Post("/session-links/{engine}/{session_id}/pulls", api.PutSessionPull) + r.Delete("/session-links/{engine}/{session_id}/pulls/{number}", api.DeleteSessionPull) return r } diff --git a/server/internal/handler/memory.go b/server/internal/handler/memory.go index b9a0de2..98659b3 100644 --- a/server/internal/handler/memory.go +++ b/server/internal/handler/memory.go @@ -12,6 +12,7 @@ import ( type ListTextMemoriesRequest struct { UserID string WorkspaceID string + WorkID string } type ListTextMemoriesResponse struct { @@ -45,9 +46,10 @@ func (a *API) ListTextMemories(w http.ResponseWriter, r *http.Request) { req := ListTextMemoriesRequest{ UserID: r.URL.Query().Get("user_id"), WorkspaceID: r.URL.Query().Get("workspace_id"), + WorkID: r.URL.Query().Get("work_id"), } - if req.UserID == "" && req.WorkspaceID == "" { - writeError(w, cderr.Invalid("user_id or workspace_id is required")) + if req.UserID == "" && req.WorkspaceID == "" && req.WorkID == "" { + writeError(w, cderr.Invalid("user_id, workspace_id or work_id is required")) return } var items []memory.TextMemory @@ -67,6 +69,14 @@ func (a *API) ListTextMemories(w http.ResponseWriter, r *http.Request) { } items = append(items, listed...) } + if req.WorkID != "" { + listed, err := memory.List(r.Context(), a.q(r.Context()), memory.ScopeWork, req.WorkID) + if err != nil { + writeError(w, err) + return + } + items = append(items, listed...) + } if items == nil { items = []memory.TextMemory{} } diff --git a/server/internal/handler/recover_flag_test.go b/server/internal/handler/recover_flag_test.go index 4ed979d..fc3ee01 100644 --- a/server/internal/handler/recover_flag_test.go +++ b/server/internal/handler/recover_flag_test.go @@ -11,7 +11,7 @@ import ( pkgagent "codedock/pkg/agent" ) -// TestNeedsRecoverHTTP 校验只有中断的 active Run 才带 needs_recover;执行中与等审批不带。 +// TestNeedsRecoverHTTP 校验只有中断的 active Run 才带 needs_recover。执行中 executing 为 true,等审批和已结束为 false。 func TestNeedsRecoverHTTP(t *testing.T) { f := newFixture(t) ctx := context.Background() @@ -30,8 +30,9 @@ func TestNeedsRecoverHTTP(t *testing.T) { if !getRun(t, f, orphaned).NeedsRecover { t.Fatal("orphaned claimed run should need recover") } - if !getSession(t, f, orphanedSess).NeedsRecover { - t.Fatal("orphaned session should need recover") + orphanedSession := getSession(t, f, orphanedSess) + if !orphanedSession.NeedsRecover || orphanedSession.Executing == nil || *orphanedSession.Executing { + t.Fatal("orphaned session should need recover and not be executing") } listed := listSessions(t, f, "") var sawOrphan bool @@ -57,8 +58,9 @@ func TestNeedsRecoverHTTP(t *testing.T) { }), }) waitNeedsRecover(t, f, liveID, false) - if getSession(t, f, liveSess).NeedsRecover { - t.Fatal("live session should not need recover") + live := getSession(t, f, liveSess) + if live.NeedsRecover || live.Executing == nil || !*live.Executing { + t.Fatal("live session should be executing") } waitSess := f.createSession(t) @@ -67,8 +69,9 @@ func TestNeedsRecoverHTTP(t *testing.T) { if getRun(t, f, waitID).NeedsRecover { t.Fatal("waiting approval should not need recover") } - if getSession(t, f, waitSess).NeedsRecover { - t.Fatal("waiting approval session should not need recover") + waiting := getSession(t, f, waitSess) + if waiting.NeedsRecover || waiting.Executing == nil || *waiting.Executing { + t.Fatal("waiting approval session should not be executing") } doneSess := f.createSession(t) @@ -83,8 +86,9 @@ func TestNeedsRecoverHTTP(t *testing.T) { if getRun(t, f, doneID).NeedsRecover { t.Fatal("completed run should not need recover") } - if getSession(t, f, doneSess).NeedsRecover { - t.Fatal("completed session should not need recover") + done := getSession(t, f, doneSess) + if done.NeedsRecover || (done.Executing != nil && *done.Executing) { + t.Fatal("completed session should not be executing") } } diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index d14d959..d03fa4b 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -190,12 +190,14 @@ func (a *API) loadSessionByID(ctx context.Context, id string) (pkgagent.Session, return mapSession(row), nil } -// attachNeedsRecover 按 Worker 是否仍在执行,给 Session 填 needs_recover。 +// attachNeedsRecover 给带 active Run 的会话填 needs_recover,并标出是否还在执行。等审批不算执行中。 func (a *API) attachNeedsRecover(ctx context.Context, session pkgagent.Session) pkgagent.Session { if session.ActiveRunID == nil || *session.ActiveRunID == "" { return session } session.NeedsRecover = a.runNeedsRecover(ctx, *session.ActiveRunID) + executing := a.nativeSessionLive(ctx, *session.ActiveRunID) + session.Executing = &executing return session } diff --git a/server/internal/pluginhost/host.go b/server/internal/pluginhost/host.go index e25ff32..3f3de06 100644 --- a/server/internal/pluginhost/host.go +++ b/server/internal/pluginhost/host.go @@ -19,6 +19,7 @@ import ( goplugin "github.com/hashicorp/go-plugin" "codedock/internal/agent/memory" + "codedock/internal/board" "codedock/internal/events" "codedock/internal/util" pkgagent "codedock/pkg/agent" @@ -416,7 +417,7 @@ func (r *hostRPC) MemoryGet(ctx context.Context, key sdk.MemoryKey) (string, err if err != nil { return "", err } - scope, scopeID := memoryScope(key.Scope, sess) + scope, scopeID := memoryScope(ctx, r.host.queries, key.Scope, sess) item, err := memory.Get(ctx, r.host.queries, memory.TextMemoryKey{ Scope: scope, ScopeID: scopeID, @@ -441,7 +442,7 @@ func (r *hostRPC) MemoryUpsert(ctx context.Context, key sdk.MemoryKey, text stri if err != nil { return err } - scope, scopeID := memoryScope(key.Scope, sess) + scope, scopeID := memoryScope(ctx, r.host.queries, key.Scope, sess) _, err = memory.Upsert(ctx, r.host.queries, memory.TextMemory{ Scope: scope, ScopeID: scopeID, @@ -498,12 +499,21 @@ func (r *hostRPC) AppendNotice(ctx context.Context, sessionID, runID, text strin return err } -// memoryScope 把插件传来的 scope 落到用户或工作区。 -func memoryScope(scope string, sess sqlite.Session) (memory.TextMemoryScope, string) { - if scope == string(memory.ScopeUser) { +// memoryScope 把插件传来的 scope 落到用户、Work 或工作区。 +func memoryScope(ctx context.Context, q *sqlite.Queries, scope string, sess sqlite.Session) (memory.TextMemoryScope, string) { + switch scope { + case string(memory.ScopeUser): return memory.ScopeUser, sess.UserID + case string(memory.ScopeWork): + if q != nil { + if place, err := board.GetPlacement(ctx, q, board.EngineNative, sess.ID); err == nil && place.WorkID != "" { + return memory.ScopeWork, place.WorkID + } + } + return memory.ScopeUser, sess.UserID + default: + return memory.ScopeWorkspace, sess.WorkspaceID } - return memory.ScopeWorkspace, sess.WorkspaceID } // nullString 把空串收成 SQL NULL。 diff --git a/server/migrations/0011_board.sql b/server/migrations/0011_board.sql new file mode 100644 index 0000000..5d39106 --- /dev/null +++ b/server/migrations/0011_board.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS works ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_works_user_updated + ON works (tenant_id, user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS work_infos ( + work_id TEXT NOT NULL, + checkout TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (work_id, checkout) +); + +CREATE TABLE IF NOT EXISTS work_checkouts ( + work_id TEXT NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY (work_id, path) +); + +CREATE TABLE IF NOT EXISTS session_placements ( + engine TEXT NOT NULL, + session_id TEXT NOT NULL, + work_id TEXT NOT NULL, + checkout TEXT NOT NULL DEFAULT '', + PRIMARY KEY (engine, session_id) +); + +CREATE INDEX IF NOT EXISTS idx_session_placements_work + ON session_placements (work_id); + +CREATE TABLE IF NOT EXISTS session_issues ( + engine TEXT NOT NULL, + session_id TEXT NOT NULL, + repo TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (engine, session_id) +); + +CREATE TABLE IF NOT EXISTS session_pulls ( + engine TEXT NOT NULL, + session_id TEXT NOT NULL, + repo TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (engine, session_id, repo, number) +); diff --git a/server/migrations/0012_session_directories.sql b/server/migrations/0012_session_directories.sql new file mode 100644 index 0000000..21f8564 --- /dev/null +++ b/server/migrations/0012_session_directories.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS session_directories ( + engine TEXT NOT NULL, + session_id TEXT NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (engine, session_id) +); diff --git a/server/migrations/0013_work_title_unique.sql b/server/migrations/0013_work_title_unique.sql new file mode 100644 index 0000000..82e9467 --- /dev/null +++ b/server/migrations/0013_work_title_unique.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_works_user_title + ON works (tenant_id, user_id, title); diff --git a/server/pkg/agent/context.go b/server/pkg/agent/context.go index 237b4dc..0150c21 100644 --- a/server/pkg/agent/context.go +++ b/server/pkg/agent/context.go @@ -17,6 +17,7 @@ type History struct { Prompt string Hidden []Message MemoryIndexes []string + Packet string } // Load 根据已准备数据构造上下文。 @@ -28,6 +29,7 @@ func Load(_ context.Context, hist History) (ContextSnapshot, error) { SystemPrompt: hist.Prompt, Hidden: hist.Hidden, MemoryIndexes: hist.MemoryIndexes, + Packet: hist.Packet, } if hist.Checkpoint != nil { snapshot.BaseEventSeq = hist.Checkpoint.BaseEventSeq @@ -87,6 +89,7 @@ func EstimateTokens(snapshot ContextSnapshot) int64 { for _, index := range snapshot.MemoryIndexes { total += CountTokens(index) } + total += CountTokens(snapshot.Packet) for _, msg := range snapshot.Hidden { total += CountTokens(DecodeText(msg.Content)) } diff --git a/server/pkg/agent/prompt.go b/server/pkg/agent/prompt.go index f695d0c..9c9bcc8 100644 --- a/server/pkg/agent/prompt.go +++ b/server/pkg/agent/prompt.go @@ -142,6 +142,9 @@ func Build(_ context.Context, req Prompt) (Chat, error) { } prefix = append(prefix, Message{Role: RoleSystem, Content: EncodeText(index)}) } + if strings.TrimSpace(req.Context.Packet) != "" { + prefix = append(prefix, Message{Role: RoleSystem, Content: EncodeText(req.Context.Packet)}) + } for _, msg := range req.Context.Hidden { if len(msg.Content) == 0 { continue diff --git a/server/pkg/agent/status.go b/server/pkg/agent/status.go index 2836109..a8ee58c 100644 --- a/server/pkg/agent/status.go +++ b/server/pkg/agent/status.go @@ -29,6 +29,16 @@ func CanTransition(from, next RunStatus) error { return fmt.Errorf("invalid run transition %q -> %q", from, next) } +// IsExecuting 判断 Run 是否还在自己往下走。等审批和终态都不算进行中。 +func IsExecuting(status RunStatus) bool { + switch status { + case RunQueued, RunLoadingContext, RunRunningLLM, RunExecutingTools, RunVerifying, RunEvaluating, RunCancelling: + return true + default: + return false + } +} + // IsTerminal 判断 Run 是否已结束。 func IsTerminal(status RunStatus) bool { switch status { diff --git a/server/pkg/agent/status_test.go b/server/pkg/agent/status_test.go index 560b417..0c510b3 100644 --- a/server/pkg/agent/status_test.go +++ b/server/pkg/agent/status_test.go @@ -50,6 +50,9 @@ func TestNeedsUserRecover(t *testing.T) { {RunFailed, false, false}, {RunCancelled, false, false}, } + if IsExecuting(RunRunningLLM) != true || IsExecuting(RunWaitingApproval) || IsExecuting(RunCompleted) { + t.Fatal("executing should exclude approval and terminal") + } for _, tc := range cases { if got := NeedsUserRecover(tc.status, tc.busy); got != tc.want { t.Fatalf("NeedsUserRecover(%s, busy=%v)=%v want %v", tc.status, tc.busy, got, tc.want) diff --git a/server/pkg/agent/types.go b/server/pkg/agent/types.go index da9d230..c880e92 100644 --- a/server/pkg/agent/types.go +++ b/server/pkg/agent/types.go @@ -199,6 +199,7 @@ type Session struct { Status SessionStatus `json:"status"` // 会话状态 ActiveRunID *string `json:"active_run_id,omitempty"` // 当前正在执行的 Run ID NeedsRecover bool `json:"needs_recover,omitempty"` // Handler 计算:active Run 已中断、需用户恢复;不入库 + Executing *bool `json:"executing,omitempty"` // Handler 计算:active Run 还在执行。等审批为 false;空表示没带 LastEventSeq int64 `json:"last_event_seq"` // 已分配的最大事件序号 CompactionSeq int64 `json:"compaction_seq"` // 上次压缩对应的事件序号 Summary string `json:"summary"` // 会话列表摘要(首条用户输入首行) @@ -279,6 +280,7 @@ type ContextSnapshot struct { ActivePlan string `json:"active_plan,omitempty"` // 本会话绑定的计划;注入 developer 范围说明 Hidden []Message `json:"hidden,omitempty"` // 插件注入的隐藏消息,不入库 MemoryIndexes []string `json:"memory_indexes,omitempty"` // 冻结记忆目录 + Packet string `json:"packet,omitempty"` // Work Packet,独立 system,不入库、不抄进记忆 EstimatedTokens int64 `json:"estimated_tokens"` // 估算 token 数 Version int64 `json:"version"` // 快照版本 } diff --git a/server/pkg/claude/approval.go b/server/pkg/claude/approval.go index 7368069..9b9a47f 100644 --- a/server/pkg/claude/approval.go +++ b/server/pkg/claude/approval.go @@ -30,3 +30,28 @@ func Decide(approvalID string, answer AskAnswer) error { func Expire(approvalID string) error { return ReplyAsk(approvalID, AskAnswer{Approved: false}) } + +// PendingAsks 列出该对话还没作答的反问。 +func PendingAsks(sessionID string) []ApprovalAsk { + rt.mu.Lock() + defer rt.mu.Unlock() + out := make([]ApprovalAsk, 0) + for _, item := range rt.asks { + if item == nil { + continue + } + turn, ok := rt.turns[item.TurnID] + if !ok || turn == nil { + continue + } + if sessionID != "" && turn.SessionID != sessionID { + continue + } + ask := item.Ask + if ask.ExternalRequestID == "" { + ask.ExternalRequestID = item.ID + } + out = append(out, ask) + } + return out +} diff --git a/server/pkg/codex/session.go b/server/pkg/codex/session.go index 4d0992b..4ab54f7 100644 --- a/server/pkg/codex/session.go +++ b/server/pkg/codex/session.go @@ -8,6 +8,7 @@ type Session struct { Preview string `json:"preview,omitempty"` Cwd string `json:"cwd,omitempty"` ActiveTurnID string `json:"active_turn_id,omitempty"` // 同时只能有一个进行中的回合。 + Running *bool `json:"running,omitempty"` // 列表计算:有进行中的回合且没有待审批。空表示没带。 Archived bool `json:"archived"` Ephemeral bool `json:"ephemeral,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` diff --git a/server/pkg/db/queries/approvals.sql b/server/pkg/db/queries/approvals.sql index 6596214..0a877b2 100644 --- a/server/pkg/db/queries/approvals.sql +++ b/server/pkg/db/queries/approvals.sql @@ -19,3 +19,21 @@ UPDATE approvals SET scope = ?, status = ?, tool_calls = ? WHERE id = ? RETURNING *; + +-- name: ListPendingApprovals :many +SELECT * FROM approvals +WHERE status = 'pending' +ORDER BY id ASC; + +-- name: ListPendingApprovalsBySession :many +SELECT * FROM approvals +WHERE session_id = ? AND status = 'pending' +ORDER BY id ASC; + +-- name: CountPendingApprovalsBySession :one +SELECT COUNT(*) FROM approvals +WHERE session_id = ? AND status = 'pending'; + +-- name: CountPendingApprovals :one +SELECT COUNT(*) FROM approvals +WHERE session_id = ? AND status = 'pending'; diff --git a/server/pkg/db/queries/board.sql b/server/pkg/db/queries/board.sql new file mode 100644 index 0000000..3b4837f --- /dev/null +++ b/server/pkg/db/queries/board.sql @@ -0,0 +1,205 @@ +-- name: GetWork :one +SELECT * FROM works +WHERE id = ?; + +-- name: ListWorksByUser :many +SELECT * FROM works +WHERE tenant_id = ? AND user_id = ? +ORDER BY updated_at DESC; + +-- name: InsertWork :one +INSERT INTO works ( + id, tenant_id, user_id, title, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING *; + +-- name: UpdateWork :one +UPDATE works +SET title = ?, updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: TouchWork :exec +UPDATE works +SET updated_at = ? +WHERE id = ?; + +-- name: DeleteWork :exec +DELETE FROM works +WHERE id = ?; + +-- name: GetWorkInfo :one +SELECT * FROM work_infos +WHERE work_id = ? AND checkout = ?; + +-- name: ListWorkInfos :many +SELECT * FROM work_infos +WHERE work_id = ?; + +-- name: UpsertWorkInfo :one +INSERT INTO work_infos ( + work_id, checkout, body, updated_at +) VALUES ( + ?, ?, ?, ? +) +ON CONFLICT (work_id, checkout) DO UPDATE SET + body = excluded.body, + updated_at = excluded.updated_at +RETURNING *; + +-- name: DeleteWorkInfos :exec +DELETE FROM work_infos +WHERE work_id = ?; + +-- name: DeleteWorkInfo :exec +DELETE FROM work_infos +WHERE work_id = ? AND checkout = ?; + +-- name: GetWorkCheckout :one +SELECT * FROM work_checkouts +WHERE work_id = ? AND path = ?; + +-- name: ListWorkCheckouts :many +SELECT * FROM work_checkouts +WHERE work_id = ? +ORDER BY path ASC; + +-- name: InsertWorkCheckout :one +INSERT INTO work_checkouts ( + work_id, path, kind +) VALUES ( + ?, ?, ? +) +RETURNING *; + +-- name: DeleteWorkCheckout :exec +DELETE FROM work_checkouts +WHERE work_id = ? AND path = ?; + +-- name: DeleteWorkCheckouts :exec +DELETE FROM work_checkouts +WHERE work_id = ?; + +-- name: GetSessionPlacement :one +SELECT * FROM session_placements +WHERE engine = ? AND session_id = ?; + +-- name: ListPlacementsByWork :many +SELECT * FROM session_placements +WHERE work_id = ? +ORDER BY session_id ASC; + +-- name: ListPlacementsByUser :many +SELECT p.engine, p.session_id, p.work_id, p.checkout +FROM session_placements p +INNER JOIN works w ON w.id = p.work_id +WHERE w.tenant_id = ? AND w.user_id = ? +ORDER BY w.updated_at DESC, p.session_id ASC; + +-- name: InsertSessionPlacement :one +INSERT INTO session_placements ( + engine, session_id, work_id, checkout +) VALUES ( + ?, ?, ?, ? +) +RETURNING *; + +-- name: UpdateSessionPlacement :one +UPDATE session_placements +SET work_id = ?, checkout = ? +WHERE engine = ? AND session_id = ? +RETURNING *; + +-- name: ClearPlacementCheckout :exec +UPDATE session_placements +SET checkout = '' +WHERE work_id = ? AND checkout = ?; + +-- name: DeleteSessionPlacement :exec +DELETE FROM session_placements +WHERE engine = ? AND session_id = ?; + +-- name: DeletePlacementsByWork :exec +DELETE FROM session_placements +WHERE work_id = ?; + +-- name: ListUngroupedNativeSessions :many +SELECT s.* +FROM sessions s +LEFT JOIN session_placements p + ON p.engine = 'native' AND p.session_id = s.id +WHERE s.tenant_id = ? AND s.user_id = ? + AND s.status != 'archived' + AND p.session_id IS NULL +ORDER BY s.updated_at DESC; + +-- name: GetSessionIssue :one +SELECT * FROM session_issues +WHERE engine = ? AND session_id = ?; + +-- name: UpsertSessionIssue :one +INSERT INTO session_issues ( + engine, session_id, repo, number, title, body, url, state, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? +) +ON CONFLICT (engine, session_id) DO UPDATE SET + repo = excluded.repo, + number = excluded.number, + title = excluded.title, + body = excluded.body, + url = excluded.url, + state = excluded.state, + updated_at = excluded.updated_at +RETURNING *; + +-- name: DeleteSessionIssue :exec +DELETE FROM session_issues +WHERE engine = ? AND session_id = ?; + +-- name: ListSessionPulls :many +SELECT * FROM session_pulls +WHERE engine = ? AND session_id = ? +ORDER BY number ASC; + +-- name: UpsertSessionPull :one +INSERT INTO session_pulls ( + engine, session_id, repo, number, title, body, url, state, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? +) +ON CONFLICT (engine, session_id, repo, number) DO UPDATE SET + title = excluded.title, + body = excluded.body, + url = excluded.url, + state = excluded.state, + updated_at = excluded.updated_at +RETURNING *; + +-- name: DeleteSessionPull :exec +DELETE FROM session_pulls +WHERE engine = ? AND session_id = ? AND repo = ? AND number = ?; + +-- name: DeleteSessionPulls :exec +DELETE FROM session_pulls +WHERE engine = ? AND session_id = ?; + +-- name: GetSessionDirectory :one +SELECT engine, session_id, path FROM session_directories +WHERE engine = ? AND session_id = ?; + +-- name: UpsertSessionDirectory :one +INSERT INTO session_directories ( + engine, session_id, path +) VALUES ( + ?, ?, ? +) +ON CONFLICT (engine, session_id) DO UPDATE SET + path = excluded.path +RETURNING *; + +-- name: DeleteSessionDirectory :exec +DELETE FROM session_directories +WHERE engine = ? AND session_id = ?; diff --git a/server/pkg/db/queries/sessions.sql b/server/pkg/db/queries/sessions.sql index 6e3da35..1a1724f 100644 --- a/server/pkg/db/queries/sessions.sql +++ b/server/pkg/db/queries/sessions.sql @@ -50,3 +50,13 @@ WHERE id = ?; UPDATE sessions SET summary = ?, updated_at = ? WHERE id = ? AND summary = ''; + +-- name: ListActiveSessions :many +SELECT * FROM sessions +WHERE status != 'archived' +ORDER BY updated_at DESC, id ASC; + +-- name: SetSessionWorkspace :exec +UPDATE sessions +SET workspace_id = ?, updated_at = ? +WHERE id = ?; diff --git a/server/pkg/db/sqlite/approvals.sql.go b/server/pkg/db/sqlite/approvals.sql.go index 4f771fa..0db95bb 100644 --- a/server/pkg/db/sqlite/approvals.sql.go +++ b/server/pkg/db/sqlite/approvals.sql.go @@ -9,6 +9,30 @@ import ( "context" ) +const countPendingApprovals = `-- name: CountPendingApprovals :one +SELECT COUNT(*) FROM approvals +WHERE session_id = ? AND status = 'pending' +` + +func (q *Queries) CountPendingApprovals(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countPendingApprovals, sessionID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countPendingApprovalsBySession = `-- name: CountPendingApprovalsBySession :one +SELECT COUNT(*) FROM approvals +WHERE session_id = ? AND status = 'pending' +` + +func (q *Queries) CountPendingApprovalsBySession(ctx context.Context, sessionID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countPendingApprovalsBySession, sessionID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countSessionApprovals = `-- name: CountSessionApprovals :one SELECT COUNT(*) FROM approvals WHERE session_id = ? @@ -91,6 +115,84 @@ func (q *Queries) InsertApproval(ctx context.Context, arg InsertApprovalParams) return i, err } +const listPendingApprovals = `-- name: ListPendingApprovals :many +SELECT id, session_id, run_id, tool_call_id, scope, status, expires_at, tool_calls, kind FROM approvals +WHERE status = 'pending' +ORDER BY id ASC +` + +func (q *Queries) ListPendingApprovals(ctx context.Context) ([]Approval, error) { + rows, err := q.db.QueryContext(ctx, listPendingApprovals) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Approval + for rows.Next() { + var i Approval + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.RunID, + &i.ToolCallID, + &i.Scope, + &i.Status, + &i.ExpiresAt, + &i.ToolCalls, + &i.Kind, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingApprovalsBySession = `-- name: ListPendingApprovalsBySession :many +SELECT id, session_id, run_id, tool_call_id, scope, status, expires_at, tool_calls, kind FROM approvals +WHERE session_id = ? AND status = 'pending' +ORDER BY id ASC +` + +func (q *Queries) ListPendingApprovalsBySession(ctx context.Context, sessionID string) ([]Approval, error) { + rows, err := q.db.QueryContext(ctx, listPendingApprovalsBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Approval + for rows.Next() { + var i Approval + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.RunID, + &i.ToolCallID, + &i.Scope, + &i.Status, + &i.ExpiresAt, + &i.ToolCalls, + &i.Kind, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateApproval = `-- name: UpdateApproval :one UPDATE approvals SET scope = ?, status = ?, tool_calls = ? diff --git a/server/pkg/db/sqlite/board.sql.go b/server/pkg/db/sqlite/board.sql.go new file mode 100644 index 0000000..fd45160 --- /dev/null +++ b/server/pkg/db/sqlite/board.sql.go @@ -0,0 +1,914 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: board.sql + +package sqlite + +import ( + "context" +) + +const clearPlacementCheckout = `-- name: ClearPlacementCheckout :exec +UPDATE session_placements +SET checkout = '' +WHERE work_id = ? AND checkout = ? +` + +type ClearPlacementCheckoutParams struct { + WorkID string + Checkout string +} + +func (q *Queries) ClearPlacementCheckout(ctx context.Context, arg ClearPlacementCheckoutParams) error { + _, err := q.db.ExecContext(ctx, clearPlacementCheckout, arg.WorkID, arg.Checkout) + return err +} + +const deletePlacementsByWork = `-- name: DeletePlacementsByWork :exec +DELETE FROM session_placements +WHERE work_id = ? +` + +func (q *Queries) DeletePlacementsByWork(ctx context.Context, workID string) error { + _, err := q.db.ExecContext(ctx, deletePlacementsByWork, workID) + return err +} + +const deleteSessionDirectory = `-- name: DeleteSessionDirectory :exec +DELETE FROM session_directories +WHERE engine = ? AND session_id = ? +` + +type DeleteSessionDirectoryParams struct { + Engine string + SessionID string +} + +func (q *Queries) DeleteSessionDirectory(ctx context.Context, arg DeleteSessionDirectoryParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionDirectory, arg.Engine, arg.SessionID) + return err +} + +const deleteSessionIssue = `-- name: DeleteSessionIssue :exec +DELETE FROM session_issues +WHERE engine = ? AND session_id = ? +` + +type DeleteSessionIssueParams struct { + Engine string + SessionID string +} + +func (q *Queries) DeleteSessionIssue(ctx context.Context, arg DeleteSessionIssueParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionIssue, arg.Engine, arg.SessionID) + return err +} + +const deleteSessionPlacement = `-- name: DeleteSessionPlacement :exec +DELETE FROM session_placements +WHERE engine = ? AND session_id = ? +` + +type DeleteSessionPlacementParams struct { + Engine string + SessionID string +} + +func (q *Queries) DeleteSessionPlacement(ctx context.Context, arg DeleteSessionPlacementParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionPlacement, arg.Engine, arg.SessionID) + return err +} + +const deleteSessionPull = `-- name: DeleteSessionPull :exec +DELETE FROM session_pulls +WHERE engine = ? AND session_id = ? AND repo = ? AND number = ? +` + +type DeleteSessionPullParams struct { + Engine string + SessionID string + Repo string + Number int64 +} + +func (q *Queries) DeleteSessionPull(ctx context.Context, arg DeleteSessionPullParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionPull, + arg.Engine, + arg.SessionID, + arg.Repo, + arg.Number, + ) + return err +} + +const deleteSessionPulls = `-- name: DeleteSessionPulls :exec +DELETE FROM session_pulls +WHERE engine = ? AND session_id = ? +` + +type DeleteSessionPullsParams struct { + Engine string + SessionID string +} + +func (q *Queries) DeleteSessionPulls(ctx context.Context, arg DeleteSessionPullsParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionPulls, arg.Engine, arg.SessionID) + return err +} + +const deleteWork = `-- name: DeleteWork :exec +DELETE FROM works +WHERE id = ? +` + +func (q *Queries) DeleteWork(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteWork, id) + return err +} + +const deleteWorkCheckout = `-- name: DeleteWorkCheckout :exec +DELETE FROM work_checkouts +WHERE work_id = ? AND path = ? +` + +type DeleteWorkCheckoutParams struct { + WorkID string + Path string +} + +func (q *Queries) DeleteWorkCheckout(ctx context.Context, arg DeleteWorkCheckoutParams) error { + _, err := q.db.ExecContext(ctx, deleteWorkCheckout, arg.WorkID, arg.Path) + return err +} + +const deleteWorkCheckouts = `-- name: DeleteWorkCheckouts :exec +DELETE FROM work_checkouts +WHERE work_id = ? +` + +func (q *Queries) DeleteWorkCheckouts(ctx context.Context, workID string) error { + _, err := q.db.ExecContext(ctx, deleteWorkCheckouts, workID) + return err +} + +const deleteWorkInfo = `-- name: DeleteWorkInfo :exec +DELETE FROM work_infos +WHERE work_id = ? AND checkout = ? +` + +type DeleteWorkInfoParams struct { + WorkID string + Checkout string +} + +func (q *Queries) DeleteWorkInfo(ctx context.Context, arg DeleteWorkInfoParams) error { + _, err := q.db.ExecContext(ctx, deleteWorkInfo, arg.WorkID, arg.Checkout) + return err +} + +const deleteWorkInfos = `-- name: DeleteWorkInfos :exec +DELETE FROM work_infos +WHERE work_id = ? +` + +func (q *Queries) DeleteWorkInfos(ctx context.Context, workID string) error { + _, err := q.db.ExecContext(ctx, deleteWorkInfos, workID) + return err +} + +const getSessionDirectory = `-- name: GetSessionDirectory :one +SELECT engine, session_id, path FROM session_directories +WHERE engine = ? AND session_id = ? +` + +type GetSessionDirectoryParams struct { + Engine string + SessionID string +} + +func (q *Queries) GetSessionDirectory(ctx context.Context, arg GetSessionDirectoryParams) (SessionDirectory, error) { + row := q.db.QueryRowContext(ctx, getSessionDirectory, arg.Engine, arg.SessionID) + var i SessionDirectory + err := row.Scan(&i.Engine, &i.SessionID, &i.Path) + return i, err +} + +const getSessionIssue = `-- name: GetSessionIssue :one +SELECT engine, session_id, repo, number, title, body, url, state, updated_at FROM session_issues +WHERE engine = ? AND session_id = ? +` + +type GetSessionIssueParams struct { + Engine string + SessionID string +} + +func (q *Queries) GetSessionIssue(ctx context.Context, arg GetSessionIssueParams) (SessionIssue, error) { + row := q.db.QueryRowContext(ctx, getSessionIssue, arg.Engine, arg.SessionID) + var i SessionIssue + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.Url, + &i.State, + &i.UpdatedAt, + ) + return i, err +} + +const getSessionPlacement = `-- name: GetSessionPlacement :one +SELECT engine, session_id, work_id, checkout FROM session_placements +WHERE engine = ? AND session_id = ? +` + +type GetSessionPlacementParams struct { + Engine string + SessionID string +} + +func (q *Queries) GetSessionPlacement(ctx context.Context, arg GetSessionPlacementParams) (SessionPlacement, error) { + row := q.db.QueryRowContext(ctx, getSessionPlacement, arg.Engine, arg.SessionID) + var i SessionPlacement + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.WorkID, + &i.Checkout, + ) + return i, err +} + +const getWork = `-- name: GetWork :one +SELECT id, tenant_id, user_id, title, created_at, updated_at FROM works +WHERE id = ? +` + +func (q *Queries) GetWork(ctx context.Context, id string) (Work, error) { + row := q.db.QueryRowContext(ctx, getWork, id) + var i Work + err := row.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getWorkCheckout = `-- name: GetWorkCheckout :one +SELECT work_id, path, kind FROM work_checkouts +WHERE work_id = ? AND path = ? +` + +type GetWorkCheckoutParams struct { + WorkID string + Path string +} + +func (q *Queries) GetWorkCheckout(ctx context.Context, arg GetWorkCheckoutParams) (WorkCheckout, error) { + row := q.db.QueryRowContext(ctx, getWorkCheckout, arg.WorkID, arg.Path) + var i WorkCheckout + err := row.Scan(&i.WorkID, &i.Path, &i.Kind) + return i, err +} + +const getWorkInfo = `-- name: GetWorkInfo :one +SELECT work_id, checkout, body, updated_at FROM work_infos +WHERE work_id = ? AND checkout = ? +` + +type GetWorkInfoParams struct { + WorkID string + Checkout string +} + +func (q *Queries) GetWorkInfo(ctx context.Context, arg GetWorkInfoParams) (WorkInfo, error) { + row := q.db.QueryRowContext(ctx, getWorkInfo, arg.WorkID, arg.Checkout) + var i WorkInfo + err := row.Scan( + &i.WorkID, + &i.Checkout, + &i.Body, + &i.UpdatedAt, + ) + return i, err +} + +const insertSessionPlacement = `-- name: InsertSessionPlacement :one +INSERT INTO session_placements ( + engine, session_id, work_id, checkout +) VALUES ( + ?, ?, ?, ? +) +RETURNING engine, session_id, work_id, checkout +` + +type InsertSessionPlacementParams struct { + Engine string + SessionID string + WorkID string + Checkout string +} + +func (q *Queries) InsertSessionPlacement(ctx context.Context, arg InsertSessionPlacementParams) (SessionPlacement, error) { + row := q.db.QueryRowContext(ctx, insertSessionPlacement, + arg.Engine, + arg.SessionID, + arg.WorkID, + arg.Checkout, + ) + var i SessionPlacement + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.WorkID, + &i.Checkout, + ) + return i, err +} + +const insertWork = `-- name: InsertWork :one +INSERT INTO works ( + id, tenant_id, user_id, title, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING id, tenant_id, user_id, title, created_at, updated_at +` + +type InsertWorkParams struct { + ID string + TenantID string + UserID string + Title string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) InsertWork(ctx context.Context, arg InsertWorkParams) (Work, error) { + row := q.db.QueryRowContext(ctx, insertWork, + arg.ID, + arg.TenantID, + arg.UserID, + arg.Title, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Work + err := row.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertWorkCheckout = `-- name: InsertWorkCheckout :one +INSERT INTO work_checkouts ( + work_id, path, kind +) VALUES ( + ?, ?, ? +) +RETURNING work_id, path, kind +` + +type InsertWorkCheckoutParams struct { + WorkID string + Path string + Kind string +} + +func (q *Queries) InsertWorkCheckout(ctx context.Context, arg InsertWorkCheckoutParams) (WorkCheckout, error) { + row := q.db.QueryRowContext(ctx, insertWorkCheckout, arg.WorkID, arg.Path, arg.Kind) + var i WorkCheckout + err := row.Scan(&i.WorkID, &i.Path, &i.Kind) + return i, err +} + +const listPlacementsByUser = `-- name: ListPlacementsByUser :many +SELECT p.engine, p.session_id, p.work_id, p.checkout +FROM session_placements p +INNER JOIN works w ON w.id = p.work_id +WHERE w.tenant_id = ? AND w.user_id = ? +ORDER BY w.updated_at DESC, p.session_id ASC +` + +type ListPlacementsByUserParams struct { + TenantID string + UserID string +} + +func (q *Queries) ListPlacementsByUser(ctx context.Context, arg ListPlacementsByUserParams) ([]SessionPlacement, error) { + rows, err := q.db.QueryContext(ctx, listPlacementsByUser, arg.TenantID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionPlacement + for rows.Next() { + var i SessionPlacement + if err := rows.Scan( + &i.Engine, + &i.SessionID, + &i.WorkID, + &i.Checkout, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlacementsByWork = `-- name: ListPlacementsByWork :many +SELECT engine, session_id, work_id, checkout FROM session_placements +WHERE work_id = ? +ORDER BY session_id ASC +` + +func (q *Queries) ListPlacementsByWork(ctx context.Context, workID string) ([]SessionPlacement, error) { + rows, err := q.db.QueryContext(ctx, listPlacementsByWork, workID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionPlacement + for rows.Next() { + var i SessionPlacement + if err := rows.Scan( + &i.Engine, + &i.SessionID, + &i.WorkID, + &i.Checkout, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSessionPulls = `-- name: ListSessionPulls :many +SELECT engine, session_id, repo, number, title, body, url, state, updated_at FROM session_pulls +WHERE engine = ? AND session_id = ? +ORDER BY number ASC +` + +type ListSessionPullsParams struct { + Engine string + SessionID string +} + +func (q *Queries) ListSessionPulls(ctx context.Context, arg ListSessionPullsParams) ([]SessionPull, error) { + rows, err := q.db.QueryContext(ctx, listSessionPulls, arg.Engine, arg.SessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionPull + for rows.Next() { + var i SessionPull + if err := rows.Scan( + &i.Engine, + &i.SessionID, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.Url, + &i.State, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUngroupedNativeSessions = `-- name: ListUngroupedNativeSessions :many +SELECT s.id, s.tenant_id, s.user_id, s.agent_id, s.status, s.active_run_id, s.last_event_seq, s.compaction_seq, s.created_at, s.updated_at, s.workspace_id, s.summary +FROM sessions s +LEFT JOIN session_placements p + ON p.engine = 'native' AND p.session_id = s.id +WHERE s.tenant_id = ? AND s.user_id = ? + AND s.status != 'archived' + AND p.session_id IS NULL +ORDER BY s.updated_at DESC +` + +type ListUngroupedNativeSessionsParams struct { + TenantID string + UserID string +} + +func (q *Queries) ListUngroupedNativeSessions(ctx context.Context, arg ListUngroupedNativeSessionsParams) ([]Session, error) { + rows, err := q.db.QueryContext(ctx, listUngroupedNativeSessions, arg.TenantID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Session + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.AgentID, + &i.Status, + &i.ActiveRunID, + &i.LastEventSeq, + &i.CompactionSeq, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.Summary, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorkCheckouts = `-- name: ListWorkCheckouts :many +SELECT work_id, path, kind FROM work_checkouts +WHERE work_id = ? +ORDER BY path ASC +` + +func (q *Queries) ListWorkCheckouts(ctx context.Context, workID string) ([]WorkCheckout, error) { + rows, err := q.db.QueryContext(ctx, listWorkCheckouts, workID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkCheckout + for rows.Next() { + var i WorkCheckout + if err := rows.Scan(&i.WorkID, &i.Path, &i.Kind); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorkInfos = `-- name: ListWorkInfos :many +SELECT work_id, checkout, body, updated_at FROM work_infos +WHERE work_id = ? +` + +func (q *Queries) ListWorkInfos(ctx context.Context, workID string) ([]WorkInfo, error) { + rows, err := q.db.QueryContext(ctx, listWorkInfos, workID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkInfo + for rows.Next() { + var i WorkInfo + if err := rows.Scan( + &i.WorkID, + &i.Checkout, + &i.Body, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorksByUser = `-- name: ListWorksByUser :many +SELECT id, tenant_id, user_id, title, created_at, updated_at FROM works +WHERE tenant_id = ? AND user_id = ? +ORDER BY updated_at DESC +` + +type ListWorksByUserParams struct { + TenantID string + UserID string +} + +func (q *Queries) ListWorksByUser(ctx context.Context, arg ListWorksByUserParams) ([]Work, error) { + rows, err := q.db.QueryContext(ctx, listWorksByUser, arg.TenantID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Work + for rows.Next() { + var i Work + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchWork = `-- name: TouchWork :exec +UPDATE works +SET updated_at = ? +WHERE id = ? +` + +type TouchWorkParams struct { + UpdatedAt string + ID string +} + +func (q *Queries) TouchWork(ctx context.Context, arg TouchWorkParams) error { + _, err := q.db.ExecContext(ctx, touchWork, arg.UpdatedAt, arg.ID) + return err +} + +const updateSessionPlacement = `-- name: UpdateSessionPlacement :one +UPDATE session_placements +SET work_id = ?, checkout = ? +WHERE engine = ? AND session_id = ? +RETURNING engine, session_id, work_id, checkout +` + +type UpdateSessionPlacementParams struct { + WorkID string + Checkout string + Engine string + SessionID string +} + +func (q *Queries) UpdateSessionPlacement(ctx context.Context, arg UpdateSessionPlacementParams) (SessionPlacement, error) { + row := q.db.QueryRowContext(ctx, updateSessionPlacement, + arg.WorkID, + arg.Checkout, + arg.Engine, + arg.SessionID, + ) + var i SessionPlacement + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.WorkID, + &i.Checkout, + ) + return i, err +} + +const updateWork = `-- name: UpdateWork :one +UPDATE works +SET title = ?, updated_at = ? +WHERE id = ? +RETURNING id, tenant_id, user_id, title, created_at, updated_at +` + +type UpdateWorkParams struct { + Title string + UpdatedAt string + ID string +} + +func (q *Queries) UpdateWork(ctx context.Context, arg UpdateWorkParams) (Work, error) { + row := q.db.QueryRowContext(ctx, updateWork, arg.Title, arg.UpdatedAt, arg.ID) + var i Work + err := row.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.Title, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertSessionDirectory = `-- name: UpsertSessionDirectory :one +INSERT INTO session_directories ( + engine, session_id, path +) VALUES ( + ?, ?, ? +) +ON CONFLICT (engine, session_id) DO UPDATE SET + path = excluded.path +RETURNING engine, session_id, path +` + +type UpsertSessionDirectoryParams struct { + Engine string + SessionID string + Path string +} + +func (q *Queries) UpsertSessionDirectory(ctx context.Context, arg UpsertSessionDirectoryParams) (SessionDirectory, error) { + row := q.db.QueryRowContext(ctx, upsertSessionDirectory, arg.Engine, arg.SessionID, arg.Path) + var i SessionDirectory + err := row.Scan(&i.Engine, &i.SessionID, &i.Path) + return i, err +} + +const upsertSessionIssue = `-- name: UpsertSessionIssue :one +INSERT INTO session_issues ( + engine, session_id, repo, number, title, body, url, state, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? +) +ON CONFLICT (engine, session_id) DO UPDATE SET + repo = excluded.repo, + number = excluded.number, + title = excluded.title, + body = excluded.body, + url = excluded.url, + state = excluded.state, + updated_at = excluded.updated_at +RETURNING engine, session_id, repo, number, title, body, url, state, updated_at +` + +type UpsertSessionIssueParams struct { + Engine string + SessionID string + Repo string + Number int64 + Title string + Body string + Url string + State string + UpdatedAt string +} + +func (q *Queries) UpsertSessionIssue(ctx context.Context, arg UpsertSessionIssueParams) (SessionIssue, error) { + row := q.db.QueryRowContext(ctx, upsertSessionIssue, + arg.Engine, + arg.SessionID, + arg.Repo, + arg.Number, + arg.Title, + arg.Body, + arg.Url, + arg.State, + arg.UpdatedAt, + ) + var i SessionIssue + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.Url, + &i.State, + &i.UpdatedAt, + ) + return i, err +} + +const upsertSessionPull = `-- name: UpsertSessionPull :one +INSERT INTO session_pulls ( + engine, session_id, repo, number, title, body, url, state, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? +) +ON CONFLICT (engine, session_id, repo, number) DO UPDATE SET + title = excluded.title, + body = excluded.body, + url = excluded.url, + state = excluded.state, + updated_at = excluded.updated_at +RETURNING engine, session_id, repo, number, title, body, url, state, updated_at +` + +type UpsertSessionPullParams struct { + Engine string + SessionID string + Repo string + Number int64 + Title string + Body string + Url string + State string + UpdatedAt string +} + +func (q *Queries) UpsertSessionPull(ctx context.Context, arg UpsertSessionPullParams) (SessionPull, error) { + row := q.db.QueryRowContext(ctx, upsertSessionPull, + arg.Engine, + arg.SessionID, + arg.Repo, + arg.Number, + arg.Title, + arg.Body, + arg.Url, + arg.State, + arg.UpdatedAt, + ) + var i SessionPull + err := row.Scan( + &i.Engine, + &i.SessionID, + &i.Repo, + &i.Number, + &i.Title, + &i.Body, + &i.Url, + &i.State, + &i.UpdatedAt, + ) + return i, err +} + +const upsertWorkInfo = `-- name: UpsertWorkInfo :one +INSERT INTO work_infos ( + work_id, checkout, body, updated_at +) VALUES ( + ?, ?, ?, ? +) +ON CONFLICT (work_id, checkout) DO UPDATE SET + body = excluded.body, + updated_at = excluded.updated_at +RETURNING work_id, checkout, body, updated_at +` + +type UpsertWorkInfoParams struct { + WorkID string + Checkout string + Body string + UpdatedAt string +} + +func (q *Queries) UpsertWorkInfo(ctx context.Context, arg UpsertWorkInfoParams) (WorkInfo, error) { + row := q.db.QueryRowContext(ctx, upsertWorkInfo, + arg.WorkID, + arg.Checkout, + arg.Body, + arg.UpdatedAt, + ) + var i WorkInfo + err := row.Scan( + &i.WorkID, + &i.Checkout, + &i.Body, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/pkg/db/sqlite/models.go b/server/pkg/db/sqlite/models.go index f9bec65..477edea 100644 --- a/server/pkg/db/sqlite/models.go +++ b/server/pkg/db/sqlite/models.go @@ -123,6 +123,24 @@ type Session struct { Summary string } +type SessionDirectory struct { + Engine string + SessionID string + Path string +} + +type SessionIssue struct { + Engine string + SessionID string + Repo string + Number int64 + Title string + Body string + Url string + State string + UpdatedAt string +} + type SessionLease struct { SessionID string RunID string @@ -132,6 +150,25 @@ type SessionLease struct { ExpiresAt string } +type SessionPlacement struct { + Engine string + SessionID string + WorkID string + Checkout string +} + +type SessionPull struct { + Engine string + SessionID string + Repo string + Number int64 + Title string + Body string + Url string + State string + UpdatedAt string +} + type StepJob struct { RunID string StepIndex int64 @@ -186,3 +223,25 @@ type UsageRecord struct { RawProviderUsage sql.NullString CreatedAt string } + +type Work struct { + ID string + TenantID string + UserID string + Title string + CreatedAt string + UpdatedAt string +} + +type WorkCheckout struct { + WorkID string + Path string + Kind string +} + +type WorkInfo struct { + WorkID string + Checkout string + Body string + UpdatedAt string +} diff --git a/server/pkg/db/sqlite/sessions.sql.go b/server/pkg/db/sqlite/sessions.sql.go index ffbe289..15a5e53 100644 --- a/server/pkg/db/sqlite/sessions.sql.go +++ b/server/pkg/db/sqlite/sessions.sql.go @@ -186,6 +186,48 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S return i, err } +const listActiveSessions = `-- name: ListActiveSessions :many +SELECT id, tenant_id, user_id, agent_id, status, active_run_id, last_event_seq, compaction_seq, created_at, updated_at, workspace_id, summary FROM sessions +WHERE status != 'archived' +ORDER BY updated_at DESC, id ASC +` + +func (q *Queries) ListActiveSessions(ctx context.Context) ([]Session, error) { + rows, err := q.db.QueryContext(ctx, listActiveSessions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Session + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.UserID, + &i.AgentID, + &i.Status, + &i.ActiveRunID, + &i.LastEventSeq, + &i.CompactionSeq, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.Summary, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setSessionSummary = `-- name: SetSessionSummary :exec UPDATE sessions SET summary = ?, updated_at = ? @@ -203,6 +245,23 @@ func (q *Queries) SetSessionSummary(ctx context.Context, arg SetSessionSummaryPa return err } +const setSessionWorkspace = `-- name: SetSessionWorkspace :exec +UPDATE sessions +SET workspace_id = ?, updated_at = ? +WHERE id = ? +` + +type SetSessionWorkspaceParams struct { + WorkspaceID string + UpdatedAt string + ID string +} + +func (q *Queries) SetSessionWorkspace(ctx context.Context, arg SetSessionWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, setSessionWorkspace, arg.WorkspaceID, arg.UpdatedAt, arg.ID) + return err +} + const updateCompactionSeq = `-- name: UpdateCompactionSeq :exec UPDATE sessions SET compaction_seq = ?, updated_at = ? diff --git a/server/pkg/git/run.go b/server/pkg/git/run.go index dc5ae30..3f25b46 100644 --- a/server/pkg/git/run.go +++ b/server/pkg/git/run.go @@ -217,6 +217,11 @@ func letterDirty(letter string) bool { return letter != "" && letter != " " && letter != "." } +// Dirty 判断这份检出是否有已跟踪改动(不含未跟踪)。 +func Dirty(state SiteState) bool { + return isDirty(state) +} + func isDirty(state SiteState) bool { for _, file := range state.Files { if file.Unmerged { @@ -232,6 +237,26 @@ func isDirty(state SiteState) bool { return false } +// SharedWriters 列出同一仓库里除当前检出外的其他 worktree 路径。 +func SharedWriters(repo Repo, checkout Checkout) ([]string, error) { + trees, err := ListWorktrees(repo) + if err != nil { + return nil, err + } + current := checkoutDir(repo, checkout) + out := make([]string, 0) + for _, tree := range trees { + if samePath(tree.Path, current) { + continue + } + if strings.TrimSpace(tree.Path) == "" { + continue + } + out = append(out, tree.Path) + } + return out, nil +} + func hasUnmerged(state SiteState) bool { for _, file := range state.Files { if file.Unmerged { diff --git a/server/pkg/github/github.go b/server/pkg/github/github.go new file mode 100644 index 0000000..b6344f8 --- /dev/null +++ b/server/pkg/github/github.go @@ -0,0 +1,169 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// Issue 是 gh issue view 的只读快照。 +type Issue struct { + Repo string `json:"repo"` + Number int64 `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Body string `json:"body"` + State string `json:"state"` +} + +// Pull 是 gh pr view 的只读快照。 +type Pull struct { + Repo string `json:"repo"` + Number int64 `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Body string `json:"body"` + State string `json:"state"` +} + +// ViewIssue 用本机 gh 读一条 Issue,不落 token。 +func ViewIssue(ctx context.Context, repo string, number int64) (Issue, error) { + raw, err := runGH(ctx, repo, "issue", "view", strconv.FormatInt(number, 10), "--json", "number,title,url,body,state") + if err != nil { + return Issue{}, err + } + var out Issue + if err := json.Unmarshal(raw, &out); err != nil { + return Issue{}, err + } + out.Repo = repo + if out.Number == 0 { + out.Number = number + } + return out, nil +} + +// ViewPull 用本机 gh 读一条 PR,不落 token、不合并。 +func ViewPull(ctx context.Context, repo string, number int64) (Pull, error) { + raw, err := runGH(ctx, repo, "pr", "view", strconv.FormatInt(number, 10), "--json", "number,title,url,body,state") + if err != nil { + return Pull{}, err + } + var out Pull + if err := json.Unmarshal(raw, &out); err != nil { + return Pull{}, err + } + out.Repo = repo + if out.Number == 0 { + out.Number = number + } + return out, nil +} + +// runGH 调本机 gh,超时 30s,不弹交互。 +func runGH(ctx context.Context, repo string, args ...string) ([]byte, error) { + if numberArg(args) == "0" { + return nil, errors.New("number is required") + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if strings.TrimSpace(repo) != "" { + args = append(args, "--repo", repo) + } + cmd := exec.CommandContext(ctx, "gh", args...) + cmd.Env = append(os.Environ(), "GH_PROMPT=0") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("%s", msg) + } + return stdout.Bytes(), nil +} + +// numberArg 取出 view 后面的编号参数。 +func numberArg(args []string) string { + for i, arg := range args { + if arg == "view" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// FormatRef 把 owner/repo#n 或 GitHub 链接收成 repo 与编号。 +func FormatRef(raw string) (repo string, number int, err error) { + repo, number, _, err = ParseLink(raw) + return repo, number, err +} + +// ParseLink 从 GitHub 链接或 owner/repo#n 取出仓库、编号和种类。种类为 issue、pull,或空表示链接里看不出来。 +func ParseLink(raw string) (repo string, number int, kind string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", 0, "", errors.New("link is required") + } + if strings.Contains(raw, "://") || strings.HasPrefix(strings.ToLower(raw), "github.com/") || strings.HasPrefix(strings.ToLower(raw), "www.github.com/") { + return parseGitHubURL(raw) + } + repo, rest := "", raw + if i := strings.LastIndex(raw, "#"); i >= 0 { + repo = strings.TrimSpace(raw[:i]) + rest = raw[i+1:] + } + n, convErr := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(rest), "#")) + if convErr != nil || n <= 0 { + return "", 0, "", errors.New("invalid issue or pull number") + } + return repo, n, "", nil +} + +// parseGitHubURL 认出 /issues/n 与 /pull/n。 +func parseGitHubURL(raw string) (repo string, number int, kind string, err error) { + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", 0, "", errors.New("invalid link") + } + host := strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") + if host != "github.com" { + return "", 0, "", errors.New("link must be a github.com URL") + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 4 || parts[0] == "" || parts[1] == "" { + return "", 0, "", errors.New("invalid github link") + } + switch parts[2] { + case "issues", "issue": + kind = "issue" + case "pull", "pulls": + kind = "pull" + default: + return "", 0, "", errors.New("link must point at an issue or pull request") + } + n, convErr := strconv.Atoi(parts[3]) + if convErr != nil || n <= 0 { + return "", 0, "", errors.New("invalid issue or pull number") + } + return parts[0] + "/" + parts[1], n, kind, nil +} diff --git a/server/pkg/github/github_test.go b/server/pkg/github/github_test.go new file mode 100644 index 0000000..544c527 --- /dev/null +++ b/server/pkg/github/github_test.go @@ -0,0 +1,75 @@ +package github + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestViewIssueAndPull(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "gh") + if runtime.GOOS == "windows" { + bin += ".bat" + if err := os.WriteFile(bin, []byte("@echo {\"number\":15,\"title\":\"bug\",\"url\":\"https://x\",\"body\":\"repro\",\"state\":\"OPEN\"}\r\n"), 0o755); err != nil { + t.Fatal(err) + } + } else { + script := "#!/bin/sh\nprintf '%s\\n' '{\"number\":15,\"title\":\"bug\",\"url\":\"https://x\",\"body\":\"repro\",\"state\":\"OPEN\"}'\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + issue, err := ViewIssue(context.Background(), "acme/app", 15) + if err != nil { + t.Fatal(err) + } + if issue.Number != 15 || issue.Title != "bug" || issue.Repo != "acme/app" { + t.Fatalf("issue=%+v", issue) + } + pull, err := ViewPull(context.Background(), "acme/app", 15) + if err != nil || pull.Title != "bug" { + t.Fatalf("pull=%+v %v", pull, err) + } + if _, err := ViewIssue(context.Background(), "acme/app", 0); err == nil { + t.Fatal("zero number") + } +} + +func TestFormatRef(t *testing.T) { + repo, number, err := FormatRef("acme/app#15") + if err != nil || repo != "acme/app" || number != 15 { + t.Fatalf("ref %s #%d %v", repo, number, err) + } + if _, _, err := FormatRef(""); err == nil { + t.Fatal("empty ref") + } +} + +func TestParseLink(t *testing.T) { + repo, number, kind, err := ParseLink("https://github.com/acme/app/issues/15") + if err != nil || repo != "acme/app" || number != 15 || kind != "issue" { + t.Fatalf("issue %s #%d %s %v", repo, number, kind, err) + } + repo, number, kind, err = ParseLink("https://github.com/acme/app/pull/8/files") + if err != nil || repo != "acme/app" || number != 8 || kind != "pull" { + t.Fatalf("pull %s #%d %s %v", repo, number, kind, err) + } + if _, _, _, err := ParseLink("https://example.com/acme/app/issues/1"); err == nil { + t.Fatal("non-github") + } +} + +func TestIssueJSONShape(t *testing.T) { + raw, err := json.Marshal(Issue{Repo: "a/b", Number: 1, Title: "t"}) + if err != nil { + t.Fatal(err) + } + if string(raw) == "" { + t.Fatal("empty") + } +}