diff --git a/.env.example b/.env.example index bc2f3c7..e3f6812 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ -# Tally AI Engine の WebSocket ポート (任意、デフォルト 4000) -AI_ENGINE_PORT=4000 +# Tally AI Engine の WebSocket ポート (任意、デフォルト 3322) +# 4000/4001/5050 等は他プロジェクトと衝突しがちなので避ける。 +AI_ENGINE_PORT=3322 -# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:4000) -# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:4000 +# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:3322) +# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:3322 # プロジェクトデータの保存先 (開発用) # 実運用では対象リポジトリ配下の .tally/ を使う diff --git a/.gitignore b/.gitignore index 3afc9cc..c9db087 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ build/ .next/ out/ +# Next.js auto-generated type reference (内容が dev/build で変動する。手で編集しない) +next-env.d.ts + # Test coverage/ *.lcov @@ -45,6 +48,9 @@ workspace/ .claude/tmp/ .gstack/ +# superpowers の中間 planning doc。実装と不整合化しやすく review noise になるため untrack。 +docs/superpowers/ + # Playwright packages/*/.playwright-tally-home/ packages/*/playwright-report/ diff --git a/README.md b/README.md index 79b5d82..044ab43 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,14 @@ cp .env.example .env # 必要なら編集 pnpm dev ``` -- frontend: http://localhost:3000 -- ai-engine: ws://localhost:4000/agent +- frontend: http://localhost:3321 +- ai-engine: ws://localhost:3322/agent `pnpm dev` は `pnpm -r --parallel dev` を呼び、frontend (Next.js dev) と ai-engine (tsx watch) を並列起動する。 ### 利用フロー (要点) -1. ブラウザで http://localhost:3000 を開く +1. ブラウザで http://localhost:3321 を開く 2. 「+ 新規プロジェクト」でフォルダ選択ダイアログから保存先を選ぶ(`~/.local/share/tally/projects/<名前>/` が提案される) 3. 任意で 1 つ以上の「コードベース」(AI が探索する対象リポジトリ)を追加して「作成」 4. UC ノードを選択 → DetailSheet の「ストーリー分解」ボタンを押下 diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 40dcd8b..01b79ba 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -137,8 +137,8 @@ User taps "UC" node → "ストーリー分解" ``` $ pnpm dev -├── frontend (Next.js dev server, :3000) -├── ai-engine (WebSocket server, :3001) +├── frontend (Next.js dev server, :3321) +├── ai-engine (WebSocket server, :3322) └── storage (inline in Next.js Route Handlers) ``` @@ -173,7 +173,7 @@ AI Engine を別プロセスにする理由: ``` ANTHROPIC_API_KEY=sk-ant-... # Claude Agent SDK -TALLY_AI_PORT=3001 # AI Engine WebSocket ポート +AI_ENGINE_PORT=3322 # AI Engine WebSocket ポート (env 名は ai-engine の loadConfig に揃える) TALLY_HOME=~/.local/share/tally # レジストリ・デフォルトプロジェクト置き場(省略時はこの値) ``` diff --git a/docs/04-roadmap.md b/docs/04-roadmap.md index 3a51bf1..fd7204d 100644 --- a/docs/04-roadmap.md +++ b/docs/04-roadmap.md @@ -21,7 +21,7 @@ - `pnpm install` がエラーなく完了 - `pnpm -r test` が通る(空のテストでOK) -- `pnpm --filter frontend dev` で http://localhost:3000 が表示される +- `pnpm --filter frontend dev` で http://localhost:3321 が表示される --- diff --git a/docs/superpowers/plans/2026-04-21-project-storage-redesign.md b/docs/superpowers/plans/2026-04-21-project-storage-redesign.md deleted file mode 100644 index 2c3adef..0000000 --- a/docs/superpowers/plans/2026-04-21-project-storage-redesign.md +++ /dev/null @@ -1,3457 +0,0 @@ -# プロジェクトストレージ再設計 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** プロジェクトをリポジトリから独立した第一級の存在に昇格させ、0 件以上の `codebases[]` を参照できるモデルに刷新。保存先は XDG 準拠のグローバルディレクトリをデフォルトにし、レジストリによる明示発見 + バックエンド駆動フォルダピッカーで作成・インポートを行う。 - -**Architecture:** `.tally/` 規約を廃止し「プロジェクト = 任意のディレクトリ」に統一。レジストリ (`~/.local/share/tally/registry.yaml`) が既知プロジェクト一覧を保持。後方互換は一切維持しない破壊的変更で、ADR-0003 は Superseded とし新 ADR 3 本を追加。 - -**Tech Stack:** TypeScript (core / storage / frontend / ai-engine)、Zod(型とバリデーション)、Next.js 15 App Router(Route Handlers)、Vitest(全パッケージ)、Testing Library(frontend)、pnpm workspaces。 - -**Spec:** `docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md` - ---- - -## 実装前の重要な前提 - -- **コードノードの型名**: spec では便宜上「code ノード」と呼んでいるが、実コードでは `coderef` を使う(`packages/core/src/schema.ts` の `NODE_TYPES`)。プラン内では `coderef` を使う -- **プロジェクト ID 形式**: `newProjectId()` は `proj-<10文字nanoid>` を返す(`packages/core/src/id.ts`)。spec の表記 `proj_abc123` はイメージで、実際は `-` 区切り -- **YAML 永続化**: `packages/storage/src/yaml.ts` の `readYaml` / `writeYaml` (Zod validation 付き) を使う。アトミック書き込みが必要な箇所は本計画の Task 3 で追加する -- **破壊的変更**: 既存ファイル・型・テストを大量に削除/書き換える。型変更を起点にコンパイルエラーを潰す順序で進める - ---- - -## ファイル構造 - -### 新規作成 -- `packages/storage/src/registry.ts` — レジストリ CRUD -- `packages/storage/src/registry.test.ts` -- `packages/storage/src/project-dir.ts` — projectDir 直下の path 解決(旧 `paths.ts` 置換) -- `packages/storage/src/project-dir.test.ts` -- `packages/frontend/src/app/api/fs/ls/route.ts` — ディレクトリ一覧 API -- `packages/frontend/src/app/api/fs/ls/route.test.ts` -- `packages/frontend/src/app/api/fs/mkdir/route.ts` — 新規フォルダ API -- `packages/frontend/src/app/api/fs/mkdir/route.test.ts` -- `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` -- `packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx` -- `packages/frontend/src/components/dialog/project-import-dialog.tsx` -- `packages/frontend/src/components/dialog/project-import-dialog.test.tsx` -- `docs/adr/0008-project-independent-from-repo.md` -- `docs/adr/0009-project-registry.md` -- `docs/adr/0010-multiple-codebases.md` - -### 削除 -- `packages/storage/src/project-resolver.ts` + `.test.ts` -- `packages/storage/src/paths.ts` + `.test.ts`(`project-dir.ts` に役割移管) -- `packages/frontend/src/app/api/workspace-candidates/route.ts` -- `packages/frontend/src/lib/project-resolver.ts`(フロント側にも残がある。後述 Task で確認) - -### 書き換え -- `packages/core/src/schema.ts`(`ProjectMetaSchema` / `ProjectMetaPatchSchema` / `CodeRefNodeSchema`) -- `packages/core/src/schema.test.ts` -- `packages/storage/src/index.ts` — export 整理 -- `packages/storage/src/init-project.ts` + `.test.ts` -- `packages/storage/src/project-store.ts` + `.test.ts` -- `packages/storage/src/clear-project.ts` + `.test.ts`(`workspaceRoot` → `projectDir` rename に伴い) -- `packages/storage/src/chat-store.ts` + `.test.ts`(同上) -- `packages/frontend/src/lib/api.ts` -- `packages/frontend/src/lib/store.ts` -- `packages/frontend/src/app/api/projects/route.ts` -- `packages/frontend/src/app/api/projects/[id]/route.ts` -- `packages/frontend/src/components/dialog/new-project-dialog.tsx` + `.test.tsx`(全面刷新) -- `packages/frontend/src/components/dialog/project-settings-dialog.tsx` + `.test.tsx`(`codebases[]` 対応に全面刷新) -- `packages/frontend/src/app/page.tsx`(トップページ、registry 駆動) -- `packages/ai-engine/src/agents/codebase-anchor.ts` + `.test.ts` -- `packages/ai-engine/src/agents/find-related-code.ts` + `.test.ts` -- `packages/ai-engine/src/agents/analyze-impact.ts` + `.test.ts` -- `packages/ai-engine/src/agents/extract-questions.ts` + `.test.ts` -- `packages/ai-engine/src/agent-runner.ts` + `.test.ts` -- `packages/frontend/src/components/ai-actions/*`(codebase 参照系 UI を codebases[] 前提に) -- `examples/sample-project/`(ディレクトリ構造刷新) -- `docs/adr/0003-git-managed-yaml.md`(Superseded に更新) -- `CLAUDE.md` / `README.md` - ---- - -## Phase 1: Core データモデル - -### Task 1: CodebaseSchema 追加と ProjectMetaSchema 刷新 - -**Files:** -- Modify: `packages/core/src/schema.ts:142-175` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/core/src/schema.test.ts` に追加: - -```ts -import { describe, expect, it } from 'vitest'; -import { CodebaseSchema, ProjectMetaSchema } from './schema'; - -describe('CodebaseSchema', () => { - it('id / label / path を必須で受け入れる', () => { - const input = { id: 'frontend', label: 'TaskFlow Web', path: '/abs/path' }; - expect(CodebaseSchema.parse(input)).toEqual(input); - }); - - it('id が空文字は拒否', () => { - expect(() => CodebaseSchema.parse({ id: '', label: 'x', path: '/abs' })).toThrow(); - }); - - it('id は kebab-case 英小文字 32 字以内', () => { - expect(() => CodebaseSchema.parse({ id: 'Frontend', label: 'x', path: '/abs' })).toThrow(); - expect(() => - CodebaseSchema.parse({ id: 'a'.repeat(33), label: 'x', path: '/abs' }), - ).toThrow(); - expect(CodebaseSchema.parse({ id: 'a', label: 'x', path: '/abs' }).id).toBe('a'); - }); -}); - -describe('ProjectMetaSchema (刷新後)', () => { - it('codebases: Codebase[] を必須で受け入れる (空配列可)', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - expect(ProjectMetaSchema.parse(meta).codebases).toEqual([]); - }); - - it('codebasePath / additionalCodebasePaths を受け入れない', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [], - codebasePath: '/x', // 旧フィールド、もう存在しない - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - // passthrough してないので余計なキーは単に無視されるが、型に存在しないことを別途検証 - const parsed = ProjectMetaSchema.parse(meta); - expect('codebasePath' in parsed).toBe(false); - }); - - it('codebases[].id の重複を拒否', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [ - { id: 'dup', label: 'A', path: '/a' }, - { id: 'dup', label: 'B', path: '/b' }, - ], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - expect(() => ProjectMetaSchema.parse(meta)).toThrow(/codebases\[\]\.id/); - }); -}); -``` - -- [ ] **Step 2: テストを走らせ失敗確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: `CodebaseSchema` / 新仕様テスト が FAIL - -- [ ] **Step 3: 最小実装** - -`packages/core/src/schema.ts` の 142〜175 行目(プロジェクトスキーマ部分)を全面書き換え: - -```ts -// ---------------------------------------------------------------------------- -// プロジェクトスキーマ -// ---------------------------------------------------------------------------- - -export const CodebaseSchema = z.object({ - id: z - .string() - .regex(/^[a-z][a-z0-9-]{0,31}$/u, { - message: 'codebase id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', - }), - label: z.string().min(1), - path: z.string().min(1), -}); - -export type Codebase = z.infer; - -// project.yaml に対応する meta のみのスキーマ。 -// ノード・エッジはファイル分割で永続化するため、ここには含めない。 -export const ProjectMetaSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - }) - .superRefine((meta, ctx) => { - const ids = meta.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - }); - -// 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 -export const ProjectSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - nodes: z.array(NodeSchema), - edges: z.array(EdgeSchema), - }) - .superRefine((p, ctx) => { - const ids = p.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - }); - -// PATCH /api/projects/:id の body スキーマ。codebases 全置換のみ許可(部分更新はしない)。 -export const ProjectMetaPatchSchema = z - .object({ - name: z.string().min(1).optional(), - description: z.string().nullable().optional(), - codebases: z.array(CodebaseSchema).optional(), - }) - .strict() - .superRefine((patch, ctx) => { - if (patch.codebases) { - const ids = patch.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - } - }); -``` - -- [ ] **Step 4: テストを走らせ成功確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: 追加した `CodebaseSchema` / `ProjectMetaSchema (刷新後)` の全テストが PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): CodebaseSchema追加、ProjectMetaSchemaをcodebases[]に刷新 - -codebasePath / additionalCodebasePaths を削除し、codebases: Codebase[] に統一。 -0件許容 + id 重複チェック。ProjectMetaPatchSchema も codebases 全置換方式に。" -``` - ---- - -### Task 2: CodeRefNode に codebaseId を必須化 - -**Files:** -- Modify: `packages/core/src/schema.ts:96-105` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/core/src/schema.test.ts` に追加: - -```ts -import { CodeRefNodeSchema } from './schema'; - -describe('CodeRefNodeSchema (codebaseId 必須化)', () => { - const base = { id: 'c-1', x: 0, y: 0, title: 't', body: 'b', type: 'coderef' as const }; - - it('codebaseId 必須', () => { - expect(() => CodeRefNodeSchema.parse(base)).toThrow(); - }); - - it('codebaseId があれば合格', () => { - expect(CodeRefNodeSchema.parse({ ...base, codebaseId: 'frontend' }).codebaseId).toBe( - 'frontend', - ); - }); - - it('codebaseId が空文字は拒否', () => { - expect(() => CodeRefNodeSchema.parse({ ...base, codebaseId: '' })).toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: `CodeRefNodeSchema (codebaseId 必須化)` が FAIL - -- [ ] **Step 3: 最小実装** - -`packages/core/src/schema.ts` の `CodeRefNodeSchema` を置き換え: - -```ts -export const CodeRefNodeSchema = z.object({ - ...baseNodeShape, - type: z.literal('coderef'), - codebaseId: z.string().min(1), - filePath: z.string().optional(), - startLine: z.number().int().nonnegative().optional(), - endLine: z.number().int().nonnegative().optional(), - summary: z.string().optional(), - // analyze-impact 由来のみ記入 (find-related-code は書かない)。spec §1 の棲み分け契約。 - impact: z.string().optional(), -}); -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): CodeRefNode に codebaseId を必須フィールドとして追加" -``` - ---- - -## Phase 2: Storage レイヤー - -### Task 3: yaml.ts に atomicWriteFile を追加 - -**Files:** -- Modify: `packages/storage/src/yaml.ts` -- Test: `packages/storage/src/yaml.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/yaml.test.ts` に追加: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { atomicWriteFile } from './yaml'; - -describe('atomicWriteFile', () => { - it('temp → rename で書き込み、既存を上書きする', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-atomic-')); - const target = path.join(dir, 'a.txt'); - await fs.writeFile(target, 'old'); - await atomicWriteFile(target, 'new'); - expect(await fs.readFile(target, 'utf8')).toBe('new'); - // 同じディレクトリに .tmp が残っていない - const entries = await fs.readdir(dir); - expect(entries.filter((e) => e.endsWith('.tmp'))).toHaveLength(0); - }); - - it('親ディレクトリが無ければエラー', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-atomic-')); - await expect( - atomicWriteFile(path.join(dir, 'nope', 'a.txt'), 'x'), - ).rejects.toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- yaml.test` -Expected: FAIL(`atomicWriteFile` 未定義) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/yaml.ts` の末尾に追加: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -// 書き込み途中のプロセスダウンでファイルが半壊するのを防ぐため、 -// 同じディレクトリに .tmp-- を書いてから rename で置き換える。 -export async function atomicWriteFile(filePath: string, data: string): Promise { - const dir = path.dirname(filePath); - const base = path.basename(filePath); - const tmp = path.join(dir, `.${base}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`); - try { - await fs.writeFile(tmp, data, 'utf8'); - await fs.rename(tmp, filePath); - } catch (err) { - // rename 失敗時は tmp を片付ける - await fs.rm(tmp, { force: true }); - throw err; - } -} -``` - -既存の `writeYaml` を atomicWriteFile 経由に修正: - -```ts -export async function writeYaml(filePath: string, value: T): Promise { - const dump = yaml.stringify(value); - await atomicWriteFile(filePath, dump); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- yaml.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/yaml.ts packages/storage/src/yaml.test.ts -git commit -m "feat(storage): atomicWriteFile を追加し writeYaml を temp→rename 方式に" -``` - ---- - -### Task 4: registry.ts (home 解決・load/save) - -**Files:** -- Create: `packages/storage/src/registry.ts` -- Test: `packages/storage/src/registry.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/registry.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - loadRegistry, - resolveRegistryPath, - resolveTallyHome, - saveRegistry, -} from './registry'; - -describe('resolveTallyHome', () => { - const orig = { ...process.env }; - afterEach(() => { - process.env = { ...orig }; - }); - - it('TALLY_HOME が最優先', () => { - process.env.TALLY_HOME = '/override'; - expect(resolveTallyHome()).toBe('/override'); - }); - - it('TALLY_HOME 未設定 + XDG_DATA_HOME あり → /tally', () => { - delete process.env.TALLY_HOME; - process.env.XDG_DATA_HOME = '/xdg'; - expect(resolveTallyHome()).toBe('/xdg/tally'); - }); - - it('両方未設定 → ~/.local/share/tally', () => { - delete process.env.TALLY_HOME; - delete process.env.XDG_DATA_HOME; - expect(resolveTallyHome()).toBe(path.join(os.homedir(), '.local', 'share', 'tally')); - }); -}); - -describe('registry load/save', () => { - let dir: string; - const orig = { ...process.env }; - - beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-reg-')); - process.env.TALLY_HOME = dir; - }); - - afterEach(async () => { - process.env = { ...orig }; - await fs.rm(dir, { recursive: true, force: true }); - }); - - it('resolveRegistryPath は /registry.yaml', () => { - expect(resolveRegistryPath()).toBe(path.join(dir, 'registry.yaml')); - }); - - it('ファイルが無ければ空 Registry を返す', async () => { - const reg = await loadRegistry(); - expect(reg).toEqual({ version: 1, projects: [] }); - }); - - it('save → load ラウンドトリップ', async () => { - const reg = { - version: 1 as const, - projects: [ - { id: 'proj-a', path: '/x/y', lastOpenedAt: '2026-04-21T00:00:00Z' }, - ], - }; - await saveRegistry(reg); - expect(await loadRegistry()).toEqual(reg); - }); - - it('壊れた YAML は例外', async () => { - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, 'registry.yaml'), '::not yaml::', 'utf8'); - await expect(loadRegistry()).rejects.toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: FAIL(registry.ts 未作成) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/registry.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { z } from 'zod'; -import { atomicWriteFile, readYaml } from './yaml'; - -// --------------------------------------------------------------------------- -// パス解決 -// --------------------------------------------------------------------------- - -// $TALLY_HOME > $XDG_DATA_HOME/tally > ~/.local/share/tally -export function resolveTallyHome(): string { - if (process.env.TALLY_HOME) return process.env.TALLY_HOME; - const xdg = process.env.XDG_DATA_HOME; - if (xdg) return path.join(xdg, 'tally'); - return path.join(os.homedir(), '.local', 'share', 'tally'); -} - -export function resolveRegistryPath(): string { - return path.join(resolveTallyHome(), 'registry.yaml'); -} - -export function resolveDefaultProjectsRoot(): string { - return path.join(resolveTallyHome(), 'projects'); -} - -// --------------------------------------------------------------------------- -// スキーマ -// --------------------------------------------------------------------------- - -export const RegistryEntrySchema = z.object({ - id: z.string().min(1), - path: z.string().min(1), - lastOpenedAt: z.string().min(1), -}); - -export const RegistrySchema = z.object({ - version: z.literal(1), - projects: z.array(RegistryEntrySchema), -}); - -export type RegistryEntry = z.infer; -export type Registry = z.infer; - -const EMPTY_REGISTRY: Registry = { version: 1, projects: [] }; - -// --------------------------------------------------------------------------- -// load / save -// --------------------------------------------------------------------------- - -export async function loadRegistry(): Promise { - const filePath = resolveRegistryPath(); - try { - await fs.stat(filePath); - } catch { - return EMPTY_REGISTRY; - } - const loaded = await readYaml(filePath, RegistrySchema); - return loaded ?? EMPTY_REGISTRY; -} - -export async function saveRegistry(reg: Registry): Promise { - const filePath = resolveRegistryPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - // atomicWriteFile を使うため、直接 YAML 文字列化 - const yaml = (await import('yaml')).default.stringify(RegistrySchema.parse(reg)); - await atomicWriteFile(filePath, yaml); -} -``` - -注: `readYaml` は `packages/storage/src/yaml.ts` で Zod validation 付き読み込みが既に実装されている。無ければこのタスクで追加する(既存を確認しつつ)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/registry.ts packages/storage/src/registry.test.ts -git commit -m "feat(storage): registry.ts 新設、TALLY_HOME/XDG準拠のパス解決と load/save" -``` - ---- - -### Task 5: registry CRUD (list/register/unregister/touch) - -**Files:** -- Modify: `packages/storage/src/registry.ts` -- Test: `packages/storage/src/registry.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/registry.test.ts` に追加: - -```ts -import { listProjects, registerProject, touchProject, unregisterProject } from './registry'; - -describe('registry CRUD', () => { - let dir: string; - const orig = { ...process.env }; - - beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-reg-')); - process.env.TALLY_HOME = dir; - }); - afterEach(async () => { - process.env = { ...orig }; - await fs.rm(dir, { recursive: true, force: true }); - }); - - it('registerProject が空 Registry にエントリを追加', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - const list = await listProjects(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe('proj-a'); - expect(list[0]?.path).toBe('/a'); - expect(list[0]?.lastOpenedAt).toMatch(/\dT\d/); - }); - - it('registerProject が既存 id を上書き(後勝ち)', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - await registerProject({ id: 'proj-a', path: '/b' }); - const list = await listProjects(); - expect(list).toHaveLength(1); - expect(list[0]?.path).toBe('/b'); - }); - - it('unregisterProject が id で削除', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - await registerProject({ id: 'proj-b', path: '/b' }); - await unregisterProject('proj-a'); - const list = await listProjects(); - expect(list.map((p) => p.id)).toEqual(['proj-b']); - }); - - it('unregisterProject は存在しない id に対して no-op', async () => { - await expect(unregisterProject('does-not-exist')).resolves.toBeUndefined(); - }); - - it('touchProject が lastOpenedAt を更新', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - const before = (await listProjects())[0]?.lastOpenedAt ?? ''; - await new Promise((r) => setTimeout(r, 10)); - await touchProject('proj-a'); - const after = (await listProjects())[0]?.lastOpenedAt ?? ''; - expect(after > before).toBe(true); - }); - - it('listProjects は lastOpenedAt 降順', async () => { - await registerProject({ id: 'a', path: '/a' }); - await new Promise((r) => setTimeout(r, 10)); - await registerProject({ id: 'b', path: '/b' }); - const list = await listProjects(); - expect(list.map((p) => p.id)).toEqual(['b', 'a']); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/registry.ts` の末尾に追加: - -```ts -// --------------------------------------------------------------------------- -// CRUD -// --------------------------------------------------------------------------- - -export async function listProjects(): Promise { - const reg = await loadRegistry(); - return [...reg.projects].sort((a, b) => b.lastOpenedAt.localeCompare(a.lastOpenedAt)); -} - -export async function registerProject(entry: { id: string; path: string }): Promise { - const reg = await loadRegistry(); - const now = new Date().toISOString(); - const next: Registry = { - version: 1, - projects: [ - ...reg.projects.filter((p) => p.id !== entry.id), - { id: entry.id, path: entry.path, lastOpenedAt: now }, - ], - }; - await saveRegistry(next); -} - -export async function unregisterProject(id: string): Promise { - const reg = await loadRegistry(); - const next: Registry = { - version: 1, - projects: reg.projects.filter((p) => p.id !== id), - }; - await saveRegistry(next); -} - -export async function touchProject(id: string): Promise { - const reg = await loadRegistry(); - const now = new Date().toISOString(); - const next: Registry = { - version: 1, - projects: reg.projects.map((p) => (p.id === id ? { ...p, lastOpenedAt: now } : p)), - }; - await saveRegistry(next); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/registry.ts packages/storage/src/registry.test.ts -git commit -m "feat(storage): registry CRUD (list/register/unregister/touch)" -``` - ---- - -### Task 6: project-dir.ts で projectDir 直下の path 解決 - -**Files:** -- Create: `packages/storage/src/project-dir.ts` -- Test: `packages/storage/src/project-dir.test.ts` -- Delete: `packages/storage/src/paths.ts`、`packages/storage/src/paths.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/project-dir.test.ts`: - -```ts -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { chatFileName, nodeFileName, resolveProjectPaths } from './project-dir'; - -describe('resolveProjectPaths', () => { - it('projectDir 直下を直接指す (.tally/ サブディレクトリを挟まない)', () => { - const paths = resolveProjectPaths('/root/my-proj'); - expect(paths.root).toBe('/root/my-proj'); - expect(paths.projectFile).toBe(path.join('/root/my-proj', 'project.yaml')); - expect(paths.nodesDir).toBe(path.join('/root/my-proj', 'nodes')); - expect(paths.edgesDir).toBe(path.join('/root/my-proj', 'edges')); - expect(paths.edgesFile).toBe(path.join('/root/my-proj', 'edges', 'edges.yaml')); - expect(paths.chatsDir).toBe(path.join('/root/my-proj', 'chats')); - }); - - it('相対パスは絶対化', () => { - const cwd = process.cwd(); - const paths = resolveProjectPaths('rel/sub'); - expect(paths.root).toBe(path.join(cwd, 'rel', 'sub')); - }); -}); - -describe('file name helpers', () => { - it('nodeFileName', () => { - expect(nodeFileName('req-abc')).toBe('req-abc.yaml'); - }); - it('chatFileName', () => { - expect(chatFileName('chat-xyz')).toBe('chat-xyz.yaml'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- project-dir.test` -Expected: FAIL(未作成) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/project-dir.ts`: - -```ts -import path from 'node:path'; - -// プロジェクトディレクトリ直下の各 path を集約。.tally/ サブディレクトリは挟まない。 -export interface ProjectPaths { - root: string; - projectFile: string; - nodesDir: string; - edgesDir: string; - edgesFile: string; - chatsDir: string; -} - -export function resolveProjectPaths(projectDir: string): ProjectPaths { - const root = path.resolve(projectDir); - return { - root, - projectFile: path.join(root, 'project.yaml'), - nodesDir: path.join(root, 'nodes'), - edgesDir: path.join(root, 'edges'), - edgesFile: path.join(root, 'edges', 'edges.yaml'), - chatsDir: path.join(root, 'chats'), - }; -} - -export function nodeFileName(id: string): string { - return `${id}.yaml`; -} - -export function chatFileName(threadId: string): string { - return `${threadId}.yaml`; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- project-dir.test` -Expected: PASS - -- [ ] **Step 5: paths.ts を削除** - -```bash -git rm packages/storage/src/paths.ts packages/storage/src/paths.test.ts -``` - -- [ ] **Step 6: コミット** - -```bash -git add packages/storage/src/project-dir.ts packages/storage/src/project-dir.test.ts -git commit -m "feat(storage): project-dir.ts 新設、paths.ts 削除 - -プロジェクトディレクトリ = 任意のディレクトリ、.tally/ サブディレクトリ規約廃止。" -``` - ---- - -### Task 7: project-store.ts を projectDir + codebases 対応に刷新 - -**Files:** -- Modify: `packages/storage/src/project-store.ts`(大幅書き換え) -- Modify: `packages/storage/src/project-store.test.ts` - -- [ ] **Step 1: テストを書き換え(既存テストは `.tally/` 前提のため全面書き直し)** - -既存テストの import 先を `resolveTallyPaths` → `resolveProjectPaths` に変え、`new FileSystemProjectStore(workspaceRoot)` を `new FileSystemProjectStore(projectDir)` に変える。`codebases: []` を meta に含める。 - -`packages/storage/src/project-store.test.ts` の先頭部(setup)例: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemProjectStore } from './project-store'; -import { resolveProjectPaths } from './project-dir'; - -let projectDir: string; -beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-proj-')); - // project.yaml + nodes/ + edges/edges.yaml の土台を作る - const paths = resolveProjectPaths(projectDir); - await fs.mkdir(paths.nodesDir, { recursive: true }); - await fs.mkdir(paths.edgesDir, { recursive: true }); - await fs.writeFile(paths.edgesFile, 'edges: []\n'); -}); -afterEach(async () => { - await fs.rm(projectDir, { recursive: true, force: true }); -}); -``` - -既存テストの ProjectMeta 生成部を全て以下パターンに統一: - -```ts -const meta = { - id: 'proj-test', - name: 'test', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', -}; -``` - -追加テスト(codebases 系): - -```ts -describe('codebases roundtrip', () => { - it('0 件の codebases を save/load', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - const loaded = await store.getProjectMeta(); - expect(loaded?.codebases).toEqual([]); - }); - - it('複数 codebases を save/load', async () => { - const store = new FileSystemProjectStore(projectDir); - const codebases = [ - { id: 'frontend', label: 'Web', path: '/a' }, - { id: 'backend', label: 'API', path: '/b' }, - ]; - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases, - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - expect((await store.getProjectMeta())?.codebases).toEqual(codebases); - }); -}); - -describe('coderef codebaseId 整合性', () => { - it('存在しない codebaseId の coderef 追加は拒否', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [{ id: 'frontend', label: 'W', path: '/a' }], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - await expect( - store.addNode({ - type: 'coderef', - x: 0, - y: 0, - title: 't', - body: 'b', - codebaseId: 'unknown', - }), - ).rejects.toThrow(/codebaseId/); - }); - - it('存在する codebaseId なら合格', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [{ id: 'frontend', label: 'W', path: '/a' }], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - const node = await store.addNode({ - type: 'coderef', - x: 0, - y: 0, - title: 't', - body: 'b', - codebaseId: 'frontend', - }); - expect(node.codebaseId).toBe('frontend'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- project-store.test` -Expected: FAIL(旧 `resolveTallyPaths` が未定義 / 新仕様未対応) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/project-store.ts` の先頭 import と `FileSystemProjectStore` コンストラクタを変更: - -```ts -import { - // ... 既存 -} from '@tally/core'; -import { nodeFileName, resolveProjectPaths } from './project-dir'; -import { readYaml, writeYaml } from './yaml'; -// ... - -export class FileSystemProjectStore implements ProjectStore { - private readonly paths: ReturnType; - - constructor(projectDir: string) { - this.paths = resolveProjectPaths(projectDir); - } - // ... 以下既存のまま、workspaceRoot 参照を paths.root に統一 -``` - -`addNode` の coderef 分岐に codebaseId 整合性検証を追加: - -```ts -async addNode(draft: D): Promise> { - if (draft.type === 'coderef') { - const meta = await this.getProjectMeta(); - const cbIds = new Set(meta?.codebases.map((c) => c.id) ?? []); - if (!cbIds.has((draft as unknown as { codebaseId: string }).codebaseId)) { - throw new Error( - `coderef.codebaseId が projectMeta.codebases に存在しない: ${(draft as unknown as { codebaseId: string }).codebaseId}`, - ); - } - } - // ... 既存ロジック -} -``` - -`updateNode` / `transmuteNode` にも同様の検証を加える(coderef に変換するケース・codebaseId 変更ケース)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- project-store.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/project-store.ts packages/storage/src/project-store.test.ts -git commit -m "refactor(storage): FileSystemProjectStore を projectDir + codebases[] に刷新 - -- コンストラクタ引数 workspaceRoot を projectDir にリネーム -- resolveTallyPaths → resolveProjectPaths 経由に -- coderef 追加/更新時に codebaseId の整合性を検証" -``` - ---- - -### Task 8: chat-store.ts と clear-project.ts を projectDir に追従 - -**Files:** -- Modify: `packages/storage/src/chat-store.ts` + `.test.ts` -- Modify: `packages/storage/src/clear-project.ts` + `.test.ts` - -- [ ] **Step 1: 既存テストを workspaceRoot → projectDir に一括 rename** - -各 `.test.ts` で以下置換: -- 変数 `workspaceRoot` → `projectDir` -- `resolveTallyPaths(workspaceRoot)` → `resolveProjectPaths(projectDir)` -- setup の `.tally/` サブディレクトリ作成を廃止し、`projectDir` 直下に `nodes/`, `edges/`, `chats/` を作る - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- chat-store.test clear-project.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`chat-store.ts`: - -```ts -import { chatFileName, resolveProjectPaths } from './project-dir'; -// ... -export class FileSystemChatStore implements ChatStore { - private readonly paths: ReturnType; - - constructor(projectDir: string) { - this.paths = resolveProjectPaths(projectDir); - } - // 以下既存ロジック、this.paths.chatsDir 参照 -} -``` - -`clear-project.ts` も同様に `workspaceRoot` → `projectDir` にリネーム、`resolveProjectPaths` 経由に。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- chat-store.test clear-project.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/chat-store.ts packages/storage/src/chat-store.test.ts \ - packages/storage/src/clear-project.ts packages/storage/src/clear-project.test.ts -git commit -m "refactor(storage): chat-store / clear-project を projectDir に追従" -``` - ---- - -### Task 9: init-project.ts を registry 登録 + codebases[] 対応に刷新 - -**Files:** -- Modify: `packages/storage/src/init-project.ts` -- Modify: `packages/storage/src/init-project.test.ts` - -- [ ] **Step 1: テストを書き換える** - -`packages/storage/src/init-project.test.ts` を全面書き直し: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initProject } from './init-project'; -import { listProjects } from './registry'; - -let tallyHome: string; -let workspace: string; -const orig = { ...process.env }; - -beforeEach(async () => { - tallyHome = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = tallyHome; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(tallyHome, { recursive: true, force: true }); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe('initProject', () => { - it('空 projectDir に project.yaml / nodes / edges を作り registry に登録', async () => { - const projectDir = path.join(workspace, 'new-proj'); - const result = await initProject({ - projectDir, - name: 'new proj', - codebases: [], - }); - expect(result.id).toMatch(/^proj-/); - expect(result.projectDir).toBe(projectDir); - expect((await fs.stat(path.join(projectDir, 'project.yaml'))).isFile()).toBe(true); - expect((await fs.stat(path.join(projectDir, 'nodes'))).isDirectory()).toBe(true); - expect((await fs.stat(path.join(projectDir, 'edges', 'edges.yaml'))).isFile()).toBe(true); - const reg = await listProjects(); - expect(reg.map((p) => p.id)).toContain(result.id); - }); - - it('codebases を受け取って保存', async () => { - const projectDir = path.join(workspace, 'with-cb'); - const codebases = [{ id: 'web', label: 'Web', path: '/w' }]; - await initProject({ projectDir, name: 'x', codebases }); - const raw = await fs.readFile(path.join(projectDir, 'project.yaml'), 'utf8'); - expect(raw).toContain('web'); - expect(raw).toContain('/w'); - }); - - it('codebases 0 件でも成功する', async () => { - const projectDir = path.join(workspace, 'no-cb'); - await expect(initProject({ projectDir, name: 'x', codebases: [] })).resolves.toBeDefined(); - }); - - it('既存の project.yaml を含む dir は拒否', async () => { - const projectDir = path.join(workspace, 'existing'); - await fs.mkdir(projectDir); - await fs.writeFile(path.join(projectDir, 'project.yaml'), 'id: old\n'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/既存の project\.yaml/); - }); - - it('非空の dir で project.yaml 無しは拒否', async () => { - const projectDir = path.join(workspace, 'dirty'); - await fs.mkdir(projectDir); - await fs.writeFile(path.join(projectDir, 'random.txt'), 'x'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/空ではありません/); - }); - - it('存在しないパスでも親ディレクトリが存在すれば成功', async () => { - const projectDir = path.join(workspace, 'fresh'); - await initProject({ projectDir, name: 'x', codebases: [] }); - expect((await fs.stat(projectDir)).isDirectory()).toBe(true); - }); - - it('親ディレクトリが存在しないパスは拒否', async () => { - const projectDir = path.join(workspace, 'missing-parent', 'sub'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/親ディレクトリ/); - }); - - it('name が空は拒否', async () => { - await expect( - initProject({ projectDir: path.join(workspace, 'p'), name: ' ', codebases: [] }), - ).rejects.toThrow(/name/); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- init-project.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/init-project.ts` を全面書き直し: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -import { newProjectId } from '@tally/core'; -import type { Codebase } from '@tally/core'; - -import { FileSystemProjectStore } from './project-store'; -import { registerProject } from './registry'; -import { resolveProjectPaths } from './project-dir'; - -export interface InitProjectInput { - projectDir: string; // 絶対または相対。相対は cwd 基準で解決 - name: string; - description?: string; - codebases: Codebase[]; -} - -export interface InitProjectResult { - id: string; - projectDir: string; -} - -export async function initProject(input: InitProjectInput): Promise { - const absDir = path.resolve(input.projectDir); - - const name = input.name.trim(); - if (name.length === 0) throw new Error('name が空'); - - // 親ディレクトリが存在するか - const parent = path.dirname(absDir); - try { - const st = await fs.stat(parent); - if (!st.isDirectory()) throw new Error(`親ディレクトリがディレクトリではない: ${parent}`); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') throw new Error(`親ディレクトリが存在しない: ${parent}`); - throw err; - } - - // projectDir 自身の状態を判定 - let exists = false; - try { - const st = await fs.stat(absDir); - if (!st.isDirectory()) throw new Error(`projectDir がディレクトリではない: ${absDir}`); - exists = true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - - if (exists) { - const entries = await fs.readdir(absDir); - if (entries.includes('project.yaml')) { - throw new Error(`既存の project.yaml が存在: ${absDir}`); - } - if (entries.length > 0) { - throw new Error(`ディレクトリが空ではありません: ${absDir}`); - } - } else { - await fs.mkdir(absDir); - } - - const paths = resolveProjectPaths(absDir); - await fs.mkdir(paths.nodesDir, { recursive: true }); - await fs.mkdir(paths.edgesDir, { recursive: true }); - await fs.writeFile(paths.edgesFile, 'edges: []\n', 'utf8'); - - const id = newProjectId(); - const now = new Date().toISOString(); - const store = new FileSystemProjectStore(absDir); - await store.saveProjectMeta({ - id, - name, - ...(input.description ? { description: input.description } : {}), - codebases: input.codebases, - createdAt: now, - updatedAt: now, - }); - - await registerProject({ id, path: absDir }); - - return { id, projectDir: absDir }; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- init-project.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/init-project.ts packages/storage/src/init-project.test.ts -git commit -m "refactor(storage): initProject を projectDir + codebases[] + registry 登録に刷新" -``` - ---- - -### Task 10: project-resolver.ts 削除と index.ts 整理 - -**Files:** -- Delete: `packages/storage/src/project-resolver.ts` + `.test.ts` -- Modify: `packages/storage/src/index.ts` - -- [ ] **Step 1: 新 index.ts を書く** - -`packages/storage/src/index.ts`: - -```ts -export const PACKAGE_NAME = '@tally/storage'; - -export { FileSystemProjectStore } from './project-store'; -export type { NodeDraft, NodePatch, ProjectStore } from './project-store'; -export { FileSystemChatStore } from './chat-store'; -export type { ChatStore, CreateChatInput } from './chat-store'; -export { - chatFileName, - nodeFileName, - resolveProjectPaths, -} from './project-dir'; -export type { ProjectPaths } from './project-dir'; -export { YamlValidationError, atomicWriteFile, readYaml, writeYaml } from './yaml'; -export { - listProjects, - loadRegistry, - registerProject, - resolveDefaultProjectsRoot, - resolveRegistryPath, - resolveTallyHome, - saveRegistry, - touchProject, - unregisterProject, -} from './registry'; -export type { Registry, RegistryEntry } from './registry'; -export { initProject } from './init-project'; -export type { InitProjectInput, InitProjectResult } from './init-project'; -export { clearProject } from './clear-project'; -export type { ClearProjectResult } from './clear-project'; -``` - -- [ ] **Step 2: project-resolver.ts を削除** - -```bash -git rm packages/storage/src/project-resolver.ts packages/storage/src/project-resolver.test.ts -``` - -- [ ] **Step 3: テスト全体確認** - -Run: `pnpm -F @tally/storage test` -Expected: PASS(storage 内部は他に依存がない) - -- [ ] **Step 4: コミット** - -```bash -git add packages/storage/src/index.ts -git commit -m "refactor(storage): project-resolver.ts 削除、index.ts の export を registry/project-dir 中心に整理" -``` - ---- - -## Phase 3: バックエンド API - -### Task 11: GET /api/fs/ls - -**Files:** -- Create: `packages/frontend/src/app/api/fs/ls/route.ts` -- Create: `packages/frontend/src/app/api/fs/ls/route.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/fs/ls/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GET } from './route'; - -let dir: string; - -beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-fs-')); -}); -afterEach(async () => { - await fs.rm(dir, { recursive: true, force: true }); -}); - -function req(pathParam?: string): Request { - const url = new URL('http://localhost/api/fs/ls'); - if (pathParam !== undefined) url.searchParams.set('path', pathParam); - return new Request(url); -} - -describe('GET /api/fs/ls', () => { - it('ディレクトリのみを返し、ファイルは含めない', async () => { - await fs.mkdir(path.join(dir, 'subA')); - await fs.mkdir(path.join(dir, '.hidden')); - await fs.writeFile(path.join(dir, 'file.txt'), 'x'); - const res = await GET(req(dir)); - expect(res.status).toBe(200); - const body = (await res.json()) as { - entries: { name: string; isHidden: boolean; hasProjectYaml: boolean }[]; - }; - const names = body.entries.map((e) => e.name).sort(); - expect(names).toEqual(['.hidden', 'subA']); - const hidden = body.entries.find((e) => e.name === '.hidden'); - expect(hidden?.isHidden).toBe(true); - }); - - it('子に project.yaml があれば hasProjectYaml: true', async () => { - const sub = path.join(dir, 'proj'); - await fs.mkdir(sub); - await fs.writeFile(path.join(sub, 'project.yaml'), 'id: x'); - const res = await GET(req(dir)); - const body = (await res.json()) as { - entries: { name: string; hasProjectYaml: boolean }[]; - }; - expect(body.entries.find((e) => e.name === 'proj')?.hasProjectYaml).toBe(true); - }); - - it('dir 自身が project.yaml を含むなら containsProjectYaml: true', async () => { - await fs.writeFile(path.join(dir, 'project.yaml'), 'id: x'); - const res = await GET(req(dir)); - const body = (await res.json()) as { containsProjectYaml: boolean }; - expect(body.containsProjectYaml).toBe(true); - }); - - it('parent は 1 階層上', async () => { - const sub = path.join(dir, 'a', 'b'); - await fs.mkdir(sub, { recursive: true }); - const res = await GET(req(sub)); - const body = (await res.json()) as { parent: string }; - expect(body.parent).toBe(path.join(dir, 'a')); - }); - - it('parent がシステムルートなら null', async () => { - const res = await GET(req('/')); - const body = (await res.json()) as { parent: string | null }; - expect(body.parent).toBeNull(); - }); - - it('path が相対パスは 400', async () => { - const res = await GET(req('relative/path')); - expect(res.status).toBe(400); - }); - - it('path が未指定なら HOME にフォールバック', async () => { - const res = await GET(req()); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe(os.homedir()); - }); - - it('path 不在は 404', async () => { - const res = await GET(req(path.join(dir, 'does-not-exist'))); - expect(res.status).toBe(404); - }); - - it('.. を含む path は path.resolve で正規化して処理', async () => { - const sub = path.join(dir, 'a'); - await fs.mkdir(sub); - const weird = `${sub}/../a`; - const res = await GET(req(weird)); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe(path.resolve(weird)); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- fs/ls/route.test` -Expected: FAIL(未作成) - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/fs/ls/route.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function GET(req: Request): Promise { - const url = new URL(req.url); - const raw = url.searchParams.get('path'); - const target = raw ?? os.homedir(); - if (!path.isAbsolute(target)) { - return NextResponse.json({ error: 'path は絶対パスのみ' }, { status: 400 }); - } - const normalized = path.resolve(target); - - let stat: Awaited>; - try { - stat = await fs.stat(normalized); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - return NextResponse.json({ error: 'ディレクトリが存在しない' }, { status: 404 }); - } - if (code === 'EACCES') { - return NextResponse.json({ error: '権限がない' }, { status: 403 }); - } - throw err; - } - if (!stat.isDirectory()) { - return NextResponse.json({ error: 'ディレクトリではない' }, { status: 400 }); - } - - const parent = path.dirname(normalized); - const parentResolved = parent === normalized ? null : parent; - - let rawEntries: import('node:fs').Dirent[]; - try { - rawEntries = await fs.readdir(normalized, { withFileTypes: true }); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EACCES') { - return NextResponse.json( - { - path: normalized, - parent: parentResolved, - entries: [], - containsProjectYaml: false, - }, - { status: 200 }, - ); - } - throw err; - } - - const entries = await Promise.all( - rawEntries - .filter((e) => e.isDirectory()) - .map(async (e) => { - const childPath = path.join(normalized, e.name); - let hasProjectYaml = false; - try { - await fs.stat(path.join(childPath, 'project.yaml')); - hasProjectYaml = true; - } catch { - /* なし */ - } - return { - name: e.name, - path: childPath, - isHidden: e.name.startsWith('.'), - hasProjectYaml, - }; - }), - ); - - let containsProjectYaml = false; - try { - await fs.stat(path.join(normalized, 'project.yaml')); - containsProjectYaml = true; - } catch { - /* なし */ - } - - return NextResponse.json( - { - path: normalized, - parent: parentResolved, - entries: entries.sort((a, b) => a.name.localeCompare(b.name, 'ja')), - containsProjectYaml, - }, - { status: 200 }, - ); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- fs/ls/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/fs/ls/ -git commit -m "feat(frontend): GET /api/fs/ls 追加(ディレクトリ一覧 + project.yaml 検出)" -``` - ---- - -### Task 12: POST /api/fs/mkdir - -**Files:** -- Create: `packages/frontend/src/app/api/fs/mkdir/route.ts` -- Create: `packages/frontend/src/app/api/fs/mkdir/route.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/fs/mkdir/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { POST } from './route'; - -let dir: string; - -beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-mkdir-')); -}); -afterEach(async () => { - await fs.rm(dir, { recursive: true, force: true }); -}); - -function req(body: unknown): Request { - return new Request('http://localhost/api/fs/mkdir', { - method: 'POST', - body: JSON.stringify(body), - }); -} - -describe('POST /api/fs/mkdir', () => { - it('新規ディレクトリを作成して 201 を返す', async () => { - const res = await POST(req({ path: dir, name: 'new-sub' })); - expect(res.status).toBe(201); - expect( - (await fs.stat(path.join(dir, 'new-sub'))).isDirectory(), - ).toBe(true); - }); - - it('既存は 409', async () => { - await fs.mkdir(path.join(dir, 'exists')); - const res = await POST(req({ path: dir, name: 'exists' })); - expect(res.status).toBe(409); - }); - - it('name に / を含むと 400', async () => { - const res = await POST(req({ path: dir, name: 'a/b' })); - expect(res.status).toBe(400); - }); - - it('name が .. は 400', async () => { - const res = await POST(req({ path: dir, name: '..' })); - expect(res.status).toBe(400); - }); - - it('name が空は 400', async () => { - const res = await POST(req({ path: dir, name: '' })); - expect(res.status).toBe(400); - }); - - it('path が相対パスは 400', async () => { - const res = await POST(req({ path: 'rel', name: 'a' })); - expect(res.status).toBe(400); - }); - - it('親 path が不在は 404', async () => { - const res = await POST(req({ path: path.join(dir, 'nope'), name: 'x' })); - expect(res.status).toBe(404); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- fs/mkdir/route.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/fs/mkdir/route.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function POST(req: Request): Promise { - const raw = (await req.json().catch(() => null)) as { - path?: unknown; - name?: unknown; - } | null; - if (!raw || typeof raw.path !== 'string' || typeof raw.name !== 'string') { - return NextResponse.json({ error: 'invalid body' }, { status: 400 }); - } - const parent = raw.path; - const name = raw.name; - - if (!path.isAbsolute(parent)) { - return NextResponse.json({ error: 'path は絶対パスのみ' }, { status: 400 }); - } - if (name.length === 0 || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - return NextResponse.json({ error: 'name が不正' }, { status: 400 }); - } - - const parentNorm = path.resolve(parent); - const target = path.resolve(parentNorm, name); - // 二重防御: 正規化後ターゲットが parent 配下であること - if (!target.startsWith(`${parentNorm}${path.sep}`) && target !== parentNorm) { - return NextResponse.json({ error: 'path traversal 検出' }, { status: 400 }); - } - - try { - const st = await fs.stat(parentNorm); - if (!st.isDirectory()) { - return NextResponse.json({ error: 'path がディレクトリではない' }, { status: 400 }); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - return NextResponse.json({ error: '親ディレクトリが存在しない' }, { status: 404 }); - } - throw err; - } - - try { - await fs.mkdir(target); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EEXIST') { - return NextResponse.json({ error: '既に存在' }, { status: 409 }); - } - throw err; - } - - return NextResponse.json({ path: target }, { status: 201 }); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- fs/mkdir/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/fs/mkdir/ -git commit -m "feat(frontend): POST /api/fs/mkdir 追加(path traversal 二重防御)" -``` - ---- - -### Task 13: GET /api/projects を registry 駆動に書き換え、workspace-candidates 削除 - -**Files:** -- Modify: `packages/frontend/src/app/api/projects/route.ts` -- Delete: `packages/frontend/src/app/api/workspace-candidates/route.ts` -- Modify: 既存 `route.test.ts` があれば更新、無ければ新規 - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/projects/route.test.ts`(無ければ新規作成): - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GET, POST } from './route'; -import { resolveTallyHome } from '@tally/storage'; - -let home: string; -let workspace: string; -const orig = { ...process.env }; - -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe('GET /api/projects', () => { - it('registry が空なら空配列', async () => { - const res = await GET(); - const body = (await res.json()) as { projects: unknown[] }; - expect(body.projects).toEqual([]); - }); - - it('POST で作ると GET に現れ、lastOpenedAt 降順で並ぶ', async () => { - await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'a'), - name: 'A', - codebases: [], - }), - }), - ); - await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'b'), - name: 'B', - codebases: [], - }), - }), - ); - const res = await GET(); - const body = (await res.json()) as { - projects: { id: string; name: string; projectDir: string }[]; - }; - expect(body.projects.map((p) => p.name)).toEqual(['B', 'A']); - }); -}); - -describe('POST /api/projects', () => { - it('codebases を受け付けて registry に登録', async () => { - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'x'), - name: 'X', - codebases: [{ id: 'web', label: 'Web', path: '/w' }], - }), - }), - ); - expect(res.status).toBe(201); - }); - - it('codebases 欠落は 400', async () => { - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: path.join(workspace, 'y'), name: 'Y' }), - }), - ); - expect(res.status).toBe(400); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/route.test` -Expected: FAIL(旧実装は `codebases` を知らない) - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/projects/route.ts`: - -```ts -import { - FileSystemProjectStore, - initProject, - listProjects, -} from '@tally/storage'; -import { CodebaseSchema } from '@tally/core'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -export const dynamic = 'force-dynamic'; - -export async function GET(): Promise { - const entries = await listProjects(); - const projects = await Promise.all( - entries.map(async (e) => { - try { - const store = new FileSystemProjectStore(e.path); - const meta = await store.getProjectMeta(); - if (!meta) return null; - return { - id: meta.id, - name: meta.name, - description: meta.description ?? null, - codebases: meta.codebases, - projectDir: e.path, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - lastOpenedAt: e.lastOpenedAt, - }; - } catch { - // path 先が壊れている等は一覧から除外(UI で別途再選択を促す) - return null; - } - }), - ); - return NextResponse.json({ - projects: projects.filter((p): p is NonNullable => p !== null), - }); -} - -const CreateBodySchema = z.object({ - projectDir: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - codebases: z.array(CodebaseSchema), -}); - -export async function POST(req: Request): Promise { - const raw = await req.json().catch(() => null); - const parsed = CreateBodySchema.safeParse(raw); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - try { - const result = await initProject(parsed.data); - return NextResponse.json(result, { status: 201 }); - } catch (err) { - return NextResponse.json({ error: String((err as Error).message ?? err) }, { status: 400 }); - } -} -``` - -- [ ] **Step 4: workspace-candidates を削除** - -```bash -git rm -r packages/frontend/src/app/api/workspace-candidates/ -``` - -- [ ] **Step 5: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/route.test` -Expected: PASS - -- [ ] **Step 6: コミット** - -```bash -git add packages/frontend/src/app/api/projects/ -git commit -m "refactor(frontend): GET/POST /api/projects を registry + codebases[] 駆動に刷新 - -workspace-candidates route を削除。" -``` - ---- - -### Task 14: registry import / unregister / touch API - -**Files:** -- Create: `packages/frontend/src/app/api/projects/import/route.ts` + `.test.ts` -- Create: `packages/frontend/src/app/api/projects/[id]/unregister/route.ts` + `.test.ts` -- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts`(touch を GET で呼ぶ) - -- [ ] **Step 1: 失敗するテストを書く(import)** - -`packages/frontend/src/app/api/projects/import/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { POST } from './route'; - -let home: string; -let ws: string; -const orig = { ...process.env }; - -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - ws = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); - await fs.rm(ws, { recursive: true, force: true }); -}); - -describe('POST /api/projects/import', () => { - it('project.yaml を含む dir を登録', async () => { - const dir = path.join(ws, 'imp'); - await fs.mkdir(dir); - await fs.writeFile( - path.join(dir, 'project.yaml'), - 'id: proj-imported\nname: imp\ncodebases: []\ncreatedAt: "2026-04-21T00:00:00Z"\nupdatedAt: "2026-04-21T00:00:00Z"\n', - ); - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir }), - }), - ); - expect(res.status).toBe(201); - const body = (await res.json()) as { id: string }; - expect(body.id).toBe('proj-imported'); - }); - - it('project.yaml が無ければ 400', async () => { - const dir = path.join(ws, 'empty'); - await fs.mkdir(dir); - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir }), - }), - ); - expect(res.status).toBe(400); - }); - - it('同じ id のプロジェクトが既に登録されていれば 409', async () => { - const dir1 = path.join(ws, 'a'); - const dir2 = path.join(ws, 'b'); - for (const d of [dir1, dir2]) { - await fs.mkdir(d); - await fs.writeFile( - path.join(d, 'project.yaml'), - 'id: proj-same\nname: s\ncodebases: []\ncreatedAt: "2026-04-21T00:00:00Z"\nupdatedAt: "2026-04-21T00:00:00Z"\n', - ); - } - const r1 = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir1 }), - }), - ); - expect(r1.status).toBe(201); - const r2 = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir2 }), - }), - ); - expect(r2.status).toBe(409); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- projects/import/route.test` -Expected: FAIL - -- [ ] **Step 3: import 実装** - -`packages/frontend/src/app/api/projects/import/route.ts`: - -```ts -import path from 'node:path'; -import { - FileSystemProjectStore, - listProjects, - registerProject, -} from '@tally/storage'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -export const dynamic = 'force-dynamic'; - -const Body = z.object({ projectDir: z.string().min(1) }); - -export async function POST(req: Request): Promise { - const parsed = Body.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - const absDir = path.resolve(parsed.data.projectDir); - const store = new FileSystemProjectStore(absDir); - const meta = await store.getProjectMeta(); - if (!meta) { - return NextResponse.json( - { error: 'project.yaml が見つからない' }, - { status: 400 }, - ); - } - const existing = await listProjects(); - if (existing.some((p) => p.id === meta.id && p.path !== absDir)) { - return NextResponse.json( - { error: `id 衝突: ${meta.id} は別のパスで既に登録されている` }, - { status: 409 }, - ); - } - await registerProject({ id: meta.id, path: absDir }); - return NextResponse.json({ id: meta.id, projectDir: absDir }, { status: 201 }); -} -``` - -- [ ] **Step 4: unregister テスト + 実装** - -`packages/frontend/src/app/api/projects/[id]/unregister/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { listProjects, registerProject } from '@tally/storage'; -import { POST } from './route'; - -let home: string; -const orig = { ...process.env }; -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); -}); - -describe('POST /api/projects/:id/unregister', () => { - it('registry から外す(ディレクトリは消さない)', async () => { - await registerProject({ id: 'proj-a', path: '/some/dir' }); - const res = await POST(new Request('http://localhost'), { - params: Promise.resolve({ id: 'proj-a' }), - }); - expect(res.status).toBe(204); - expect(await listProjects()).toEqual([]); - }); -}); -``` - -`packages/frontend/src/app/api/projects/[id]/unregister/route.ts`: - -```ts -import { unregisterProject } from '@tally/storage'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function POST( - _req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - await unregisterProject(id); - return new NextResponse(null, { status: 204 }); -} -``` - -- [ ] **Step 5: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- projects/import/ projects/\\[id\\]/unregister/` -Expected: PASS - -- [ ] **Step 6: コミット** - -```bash -git add packages/frontend/src/app/api/projects/import/ \ - packages/frontend/src/app/api/projects/\[id\]/unregister/ -git commit -m "feat(frontend): /api/projects/import と /api/projects/:id/unregister を追加" -``` - ---- - -### Task 15: /api/projects/[id]/route.ts を codebases 対応に - -**Files:** -- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts` -- Modify: `packages/frontend/src/app/api/projects/[id]/route.test.ts` - -- [ ] **Step 1: テストを更新** - -既存テストで `codebasePath` / `additionalCodebasePaths` を使っている箇所を全て `codebases` に置換。追加テスト: - -```ts -it('PATCH codebases を受け付ける', async () => { - // セットアップでプロジェクト作成後 - const patch = { codebases: [{ id: 'a', label: 'A', path: '/a' }] }; - const res = await PATCH( - new Request('http://localhost', { - method: 'PATCH', - body: JSON.stringify(patch), - }), - { params: Promise.resolve({ id: projectId }) }, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { codebases: unknown }; - expect(body.codebases).toEqual(patch.codebases); -}); - -it('GET 時に touchProject が呼ばれて lastOpenedAt が更新される', async () => { - const before = Date.now(); - await GET(new Request('http://localhost'), { params: Promise.resolve({ id: projectId }) }); - const list = await listProjects(); - const entry = list.find((p) => p.id === projectId); - expect(entry).toBeDefined(); - expect(new Date(entry?.lastOpenedAt ?? '').getTime() >= before).toBe(true); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/\\[id\\]/route.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`packages/frontend/src/app/api/projects/[id]/route.ts` で: -- project discovery を `listProjects()` + `FileSystemProjectStore` に置換 -- GET 時に `touchProject(id)` -- PATCH は `ProjectMetaPatchSchema` で検証し、codebases 全置換 - -シグネチャ例: - -```ts -import { - FileSystemProjectStore, - listProjects, - touchProject, -} from '@tally/storage'; -import { ProjectMetaPatchSchema } from '@tally/core'; -// ... - -async function resolveDir(id: string): Promise { - const list = await listProjects(); - return list.find((p) => p.id === id)?.path ?? null; -} - -export async function GET( - _req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - const dir = await resolveDir(id); - if (!dir) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const store = new FileSystemProjectStore(dir); - const project = await store.loadProject(); - if (!project) return NextResponse.json({ error: 'not found' }, { status: 404 }); - await touchProject(id); - return NextResponse.json(project); -} - -export async function PATCH( - req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - const dir = await resolveDir(id); - if (!dir) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const parsed = ProjectMetaPatchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - const store = new FileSystemProjectStore(dir); - const current = await store.getProjectMeta(); - if (!current) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const next = { - ...current, - ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), - ...(parsed.data.description !== undefined - ? parsed.data.description === null - ? ({} as Record) // description 削除 - : { description: parsed.data.description } - : {}), - ...(parsed.data.codebases !== undefined ? { codebases: parsed.data.codebases } : {}), - updatedAt: new Date().toISOString(), - }; - if (parsed.data.description === null) delete (next as { description?: unknown }).description; - await store.saveProjectMeta(next); - return NextResponse.json(next); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/\\[id\\]/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/projects/\[id\]/route.ts \ - packages/frontend/src/app/api/projects/\[id\]/route.test.ts -git commit -m "refactor(frontend): /api/projects/[id] を codebases[] + registry 駆動に刷新" -``` - ---- - -## Phase 4: Frontend ライブラリ層 - -### Task 16: api.ts クライアントを新 API に揃える - -**Files:** -- Modify: `packages/frontend/src/lib/api.ts` + `.test.ts` - -- [ ] **Step 1: テスト更新** - -`api.test.ts` で `fetchWorkspaceCandidates` / `WorkspaceCandidate` を参照している箇所を削除し、新 API のテストを追加: - -```ts -describe('registry clients', () => { - it('fetchRegistryProjects が /api/projects を叩いて projects を返す', async () => { - // fetch mock - global.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ projects: [{ id: 'a', name: 'A', codebases: [] }] }), { - status: 200, - }), - ); - const list = await fetchRegistryProjects(); - expect(list[0]?.id).toBe('a'); - }); - - it('importProject が POST /api/projects/import を叩く', async () => { - global.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ id: 'x', projectDir: '/x' }), { status: 201 }), - ); - const res = await importProject('/some/dir'); - expect(res.id).toBe('x'); - }); - - it('listDirectory が /api/fs/ls を叩く', async () => { - global.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - path: '/a', - parent: null, - entries: [], - containsProjectYaml: false, - }), - { status: 200 }, - ), - ); - const res = await listDirectory('/a'); - expect(res.path).toBe('/a'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- lib/api.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`packages/frontend/src/lib/api.ts` の旧 workspace-candidates 系を削除、新規クライアントを追加: - -```ts -// 旧 WorkspaceCandidate / fetchWorkspaceCandidates を削除。 - -export interface CodebaseDto { - id: string; - label: string; - path: string; -} - -export interface RegistryProjectDto { - id: string; - name: string; - description: string | null; - codebases: CodebaseDto[]; - projectDir: string; - createdAt: string; - updatedAt: string; - lastOpenedAt: string; -} - -export async function fetchRegistryProjects(): Promise { - const res = await fetch('/api/projects'); - if (!res.ok) throw new Error(`API GET /api/projects ${res.status}`); - const body = (await res.json()) as { projects: RegistryProjectDto[] }; - return body.projects; -} - -export interface CreateProjectInput { - projectDir: string; - name: string; - description?: string; - codebases: CodebaseDto[]; -} - -export async function createProject( - input: CreateProjectInput, -): Promise<{ id: string; projectDir: string }> { - const res = await fetch('/api/projects', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/projects ${res.status}`); - } - return (await res.json()) as { id: string; projectDir: string }; -} - -export async function importProject( - projectDir: string, -): Promise<{ id: string; projectDir: string }> { - const res = await fetch('/api/projects/import', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ projectDir }), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/projects/import ${res.status}`); - } - return (await res.json()) as { id: string; projectDir: string }; -} - -export async function unregisterProjectApi(id: string): Promise { - const res = await fetch(`/api/projects/${encodeURIComponent(id)}/unregister`, { - method: 'POST', - }); - if (!res.ok) throw new Error(`POST /unregister ${res.status}`); -} - -export interface FsEntry { - name: string; - path: string; - isHidden: boolean; - hasProjectYaml: boolean; -} - -export interface FsListResult { - path: string; - parent: string | null; - entries: FsEntry[]; - containsProjectYaml: boolean; -} - -export async function listDirectory(path?: string): Promise { - const url = new URL('/api/fs/ls', window.location.origin); - if (path !== undefined) url.searchParams.set('path', path); - const res = await fetch(url.toString()); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `GET /api/fs/ls ${res.status}`); - } - return (await res.json()) as FsListResult; -} - -export async function mkdir( - parentPath: string, - name: string, -): Promise<{ path: string }> { - const res = await fetch('/api/fs/mkdir', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ path: parentPath, name }), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/fs/mkdir ${res.status}`); - } - return (await res.json()) as { path: string }; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- lib/api.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/lib/api.ts packages/frontend/src/lib/api.test.ts -git commit -m "refactor(frontend/lib): api.ts を registry + fs + codebases[] に刷新" -``` - ---- - -### Task 17: store.ts を codebases[] 対応に - -**Files:** -- Modify: `packages/frontend/src/lib/store.ts` + `.test.ts` - -- [ ] **Step 1: テスト更新** - -旧 `codebasePath` / `additionalCodebasePaths` 参照を `codebases` に全置換し、`patchProjectMeta` の codebases 全置換テストを追加。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- lib/store.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`store.ts` の `patchProjectMeta` シグネチャを: - -```ts -patchProjectMeta: (patch: { name?: string; description?: string | null; codebases?: Codebase[] }) => Promise; -``` - -に変更し、内部で `/api/projects/[id]` PATCH を叩く。`Codebase` は `@tally/core` から import。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- lib/store.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/lib/store.ts packages/frontend/src/lib/store.test.ts -git commit -m "refactor(frontend/lib): store.patchProjectMeta を codebases[] 対応に" -``` - ---- - -## Phase 5: Frontend ダイアログ & ページ - -### Task 18: FolderBrowserDialog - -**Files:** -- Create: `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` -- Create: `packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx` - -- [ ] **Step 1: 失敗するテストを書く** - -`folder-browser-dialog.test.tsx`: - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FolderBrowserDialog } from './folder-browser-dialog'; - -beforeEach(() => { - global.fetch = vi.fn().mockImplementation((url: string) => { - const u = new URL(url, 'http://localhost'); - if (u.pathname === '/api/fs/ls') { - const p = u.searchParams.get('path') ?? '/home/you'; - const entries = - p === '/home/you' - ? [ - { name: 'acme', path: '/home/you/acme', isHidden: false, hasProjectYaml: false }, - { name: '.ssh', path: '/home/you/.ssh', isHidden: true, hasProjectYaml: false }, - ] - : []; - return Promise.resolve( - new Response( - JSON.stringify({ - path: p, - parent: p === '/' ? null : '/', - entries, - containsProjectYaml: false, - }), - { status: 200 }, - ), - ); - } - if (u.pathname === '/api/fs/mkdir') { - return Promise.resolve( - new Response(JSON.stringify({ path: '/home/you/new-dir' }), { status: 201 }), - ); - } - return Promise.reject(new Error('unexpected')); - }) as typeof fetch; -}); - -describe('FolderBrowserDialog', () => { - it('初期表示で initialPath の中身を一覧表示', async () => { - render( - {}} - onClose={() => {}} - />, - ); - expect(await screen.findByText('acme')).toBeInTheDocument(); - }); - - it('隠しフォルダはデフォルト非表示、トグルで表示', async () => { - render( - {}} - onClose={() => {}} - />, - ); - await screen.findByText('acme'); - expect(screen.queryByText('.ssh')).not.toBeInTheDocument(); - await userEvent.click(screen.getByLabelText('隠しフォルダを表示')); - expect(await screen.findByText('.ssh')).toBeInTheDocument(); - }); - - it('「選択」で onConfirm に現在のパスを渡す', async () => { - const onConfirm = vi.fn(); - render( - {}} - />, - ); - await screen.findByText('acme'); - await userEvent.click(screen.getByRole('button', { name: '選択' })); - expect(onConfirm).toHaveBeenCalledWith('/home/you'); - }); - - it('import-project で project.yaml 無しなら「選択」は disabled', async () => { - render( - {}} - onClose={() => {}} - />, - ); - await screen.findByText('acme'); - expect(screen.getByRole('button', { name: '選択' })).toBeDisabled(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- folder-browser-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`folder-browser-dialog.tsx`(骨格): - -```tsx -'use client'; - -import { useCallback, useEffect, useState } from 'react'; - -import { listDirectory, mkdir, type FsListResult } from '@/lib/api'; - -export interface FolderBrowserDialogProps { - open: boolean; - initialPath?: string; - purpose: 'create-project' | 'import-project' | 'add-codebase'; - onConfirm: (absolutePath: string) => void; - onClose: () => void; -} - -export function FolderBrowserDialog(props: FolderBrowserDialogProps) { - const [listing, setListing] = useState(null); - const [error, setError] = useState(null); - const [showHidden, setShowHidden] = useState(false); - const [newDirName, setNewDirName] = useState(''); - - const load = useCallback(async (targetPath?: string) => { - setError(null); - try { - const res = await listDirectory(targetPath); - setListing(res); - } catch (err) { - setError(String((err as Error).message ?? err)); - } - }, []); - - useEffect(() => { - if (props.open) void load(props.initialPath); - }, [props.open, props.initialPath, load]); - - if (!props.open) return null; - - const confirmDisabled = - listing === null || - (props.purpose === 'import-project' && !listing.containsProjectYaml); - - const visibleEntries = (listing?.entries ?? []).filter((e) => showHidden || !e.isHidden); - - const onConfirmClick = () => { - if (!listing) return; - props.onConfirm(listing.path); - }; - - const onCreateDir = async () => { - if (!listing || newDirName.trim().length === 0) return; - try { - const res = await mkdir(listing.path, newDirName.trim()); - setNewDirName(''); - await load(res.path); - } catch (err) { - setError(String((err as Error).message ?? err)); - } - }; - - return ( -
-
-

{titleFor(props.purpose)}

-
- void load(e.target.value)} - aria-label="現在のパス" - /> - -
- {error &&
{error}
} -
    - {visibleEntries.map((e) => ( -
  • - -
  • - ))} -
- -
- setNewDirName(e.target.value)} - aria-label="新規フォルダ名" - /> - -
-
- - -
-
-
- ); -} - -function titleFor(purpose: FolderBrowserDialogProps['purpose']): string { - switch (purpose) { - case 'create-project': - return 'プロジェクトルートを選択'; - case 'import-project': - return '既存プロジェクトを選択'; - case 'add-codebase': - return 'コードベースのリポジトリを選択'; - } -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- folder-browser-dialog.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/folder-browser-dialog.tsx \ - packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx -git commit -m "feat(frontend): FolderBrowserDialog 追加(/api/fs/ls ・ /api/fs/mkdir 駆動)" -``` - ---- - -### Task 19: NewProjectDialog 刷新 - -**Files:** -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.tsx`(全面刷新) -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.test.tsx`(全面刷新) - -- [ ] **Step 1: テストを書き換える** - -`new-project-dialog.test.tsx`: - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { NewProjectDialog } from './new-project-dialog'; - -const push = vi.fn(); -vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); - -beforeEach(() => { - push.mockReset(); - global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith('/api/projects') && init?.method === 'POST') { - return new Response(JSON.stringify({ id: 'proj-new', projectDir: '/x' }), { status: 201 }); - } - // FolderBrowserDialog 内の /api/fs/ls - return new Response( - JSON.stringify({ - path: '/home/you', - parent: null, - entries: [], - containsProjectYaml: false, - }), - { status: 200 }, - ); - }) as typeof fetch; -}); - -describe('NewProjectDialog', () => { - it('名前が空なら「作成」は disabled', () => { - render( {}} />); - expect(screen.getByRole('button', { name: /作成/ })).toBeDisabled(); - }); - - it('codebases 0 件でも「作成」は押せる', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), '思考ログ'); - expect(screen.getByRole('button', { name: /作成/ })).toBeEnabled(); - }); - - it('作成成功時に /projects/:id へ遷移', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), '思考ログ'); - await userEvent.click(screen.getByRole('button', { name: /作成/ })); - await screen.findByText(/作成中|作成/); // busy or complete - expect(push).toHaveBeenCalledWith('/projects/proj-new'); - }); - - it('codebases[].id 重複は「作成」disabled', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), 'p'); - // 内部に 2 件手動入力する UI を前提。同じ id を入れたときに disabled - // (実装後に具体的な testid を決めてここを埋める) - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- new-project-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 実装(概略)** - -`new-project-dialog.tsx`: - -```tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import type { Codebase } from '@tally/core'; -import { createProject } from '@/lib/api'; -import { FolderBrowserDialog } from './folder-browser-dialog'; - -interface Props { - open: boolean; - onClose: () => void; -} - -export function NewProjectDialog({ open, onClose }: Props) { - const router = useRouter(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [projectDir, setProjectDir] = useState(''); - const [codebases, setCodebases] = useState([]); - const [pickerFor, setPickerFor] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - if (!open) return null; - - const duplicateIds = new Set(); - const seen = new Set(); - for (const c of codebases) { - if (seen.has(c.id)) duplicateIds.add(c.id); - seen.add(c.id); - } - const disabled = - busy || name.trim().length === 0 || projectDir.trim().length === 0 || duplicateIds.size > 0; - - const onPickRoot = (p: string) => { - setProjectDir(p); - setPickerFor(null); - }; - - const onPickCodebase = (p: string) => { - const slug = p.split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]/g, '-') ?? 'cb'; - let id = slug.slice(0, 32); - if (id.length === 0) id = 'cb'; - while (codebases.some((c) => c.id === id)) id = `${id.slice(0, 28)}-${Math.random().toString(36).slice(2, 4)}`; - setCodebases([...codebases, { id, label: slug, path: p }]); - setPickerFor(null); - }; - - const onSubmit = async () => { - setBusy(true); - setError(null); - try { - const res = await createProject({ - projectDir, - name: name.trim(), - ...(description.trim().length > 0 ? { description: description.trim() } : {}), - codebases, - }); - router.push(`/projects/${encodeURIComponent(res.id)}`); - } catch (e) { - setError(String((e as Error).message ?? e)); - setBusy(false); - } - }; - - return ( -
- - -
- 保存先: {projectDir || '(未選択)'} - -
-
- コードベース: -
    - {codebases.map((c, i) => ( -
  • - { - const next = [...codebases]; - next[i] = { ...c, id: e.target.value }; - setCodebases(next); - }} - /> - { - const next = [...codebases]; - next[i] = { ...c, label: e.target.value }; - setCodebases(next); - }} - /> - {c.path} - {duplicateIds.has(c.id) && id 重複} - -
  • - ))} -
- -
- {error &&
{error}
} -
- - -
- setPickerFor(null)} - /> -
- ); -} -``` - -注: デフォルトのプロジェクトルートパス提案(`/projects//`)は別 API が必要(Task 20 で `/api/projects/default-path?name=...` として追加する手もあるが、最小スコープでは projectDir の初期値を空にしてユーザーに明示選択させる形で OK。ただし UX 的には不便なので Task 20 で追加する)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- new-project-dialog.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/new-project-dialog.tsx \ - packages/frontend/src/components/dialog/new-project-dialog.test.tsx -git commit -m "feat(frontend): NewProjectDialog を FolderBrowserDialog + codebases[] 対応に全面刷新" -``` - ---- - -### Task 20: デフォルトパス提案 API と NewProjectDialog 連携 - -**Files:** -- Create: `packages/frontend/src/app/api/projects/default-path/route.ts` + `.test.ts` -- Modify: `packages/frontend/src/lib/api.ts`(`fetchDefaultProjectPath` 追加) -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.tsx`(初期値補填) - -- [ ] **Step 1: 失敗するテストを書く** - -`default-path/route.test.ts`: - -```ts -import { describe, expect, it } from 'vitest'; -import { GET } from './route'; - -describe('GET /api/projects/default-path', () => { - it('name を slug 化してデフォルトパス候補を返す', async () => { - const url = 'http://localhost/api/projects/default-path?name=My%20Proj%21'; - const res = await GET(new Request(url)); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toMatch(/\/projects\/my-proj$/); - }); - - it('衝突時にサフィックスを付与', async () => { - // resolveDefaultProjectsRoot に同名 dir を作って干渉させる - // 詳細は実装と合わせて書き直す - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認 → 実装 → 成功確認 → 連携 → コミット** - -(Task 19 と同様の TDD サイクル。実装詳細は省略可だが、`packages/storage` の `resolveDefaultProjectsRoot` を活用し、slug 化と衝突回避を行う。NewProjectDialog 側は name 入力時に debounce でこの API を叩き projectDir を初期値に入れる) - -```bash -git add packages/frontend/src/app/api/projects/default-path/ \ - packages/frontend/src/lib/api.ts \ - packages/frontend/src/components/dialog/new-project-dialog.tsx -git commit -m "feat(frontend): プロジェクト作成時にデフォルト保存先をサーバー提案" -``` - ---- - -### Task 21: ProjectImportDialog - -**Files:** -- Create: `packages/frontend/src/components/dialog/project-import-dialog.tsx` -- Create: `packages/frontend/src/components/dialog/project-import-dialog.test.tsx` - -- [ ] **Step 1: 失敗するテストを書く** - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ProjectImportDialog } from './project-import-dialog'; - -const push = vi.fn(); -vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); - -beforeEach(() => { - push.mockReset(); - global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith('/api/projects/import') && init?.method === 'POST') { - return new Response(JSON.stringify({ id: 'proj-imp', projectDir: '/x' }), { status: 201 }); - } - return new Response( - JSON.stringify({ - path: '/home/you', - parent: null, - entries: [ - { name: 'existing', path: '/home/you/existing', isHidden: false, hasProjectYaml: true }, - ], - containsProjectYaml: false, - }), - { status: 200 }, - ); - }) as typeof fetch; -}); - -describe('ProjectImportDialog', () => { - it('project.yaml を含む dir を選び「インポート」で /api/projects/import を叩く', async () => { - render( {}} />); - // folder browser で existing を選択 - await userEvent.click(await screen.findByText('existing', { exact: false })); - // containsProjectYaml: true のディレクトリで「選択」が有効 - // 実装時に具体の操作を詳細化 - }); -}); -``` - -- [ ] **Step 2-5: 実装 → 成功確認 → コミット** - -(FolderBrowserDialog を purpose='import-project' で呼び、返り値を受けて `importProject()` を叩いて `/projects/[id]` に遷移) - -```bash -git add packages/frontend/src/components/dialog/project-import-dialog.tsx \ - packages/frontend/src/components/dialog/project-import-dialog.test.tsx -git commit -m "feat(frontend): ProjectImportDialog 追加" -``` - ---- - -### Task 22: ProjectSettingsDialog を codebases[] 対応に全面刷新 - -**Files:** -- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.tsx` -- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.test.tsx` - -- [ ] **Step 1: テストを書き換える** - -旧 `codebasePath` / `additionalCodebasePaths` 入力 UI のテストを削除し、`codebases[]` の追加・削除・並び替え・ラベル編集の新 UI のテストに置換。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- project-settings-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -NewProjectDialog の codebases セクションと同じ UI パーツを抽出して共通化(簡易版でよい)、FolderBrowserDialog(purpose: 'add-codebase')で codebase を追加、`patchProjectMeta({ codebases: nextList })` を呼ぶ。 - -codebases 0 件にする操作は、使用中の coderef が無い場合のみ許可(store.ts 側で検証、エラー時はダイアログ内で表示)。 - -- [ ] **Step 4: テスト成功確認 → Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/project-settings-dialog.tsx \ - packages/frontend/src/components/dialog/project-settings-dialog.test.tsx -git commit -m "refactor(frontend): ProjectSettingsDialog を codebases[] 管理に全面刷新" -``` - ---- - -### Task 23: トップページ(projects 一覧)を registry 駆動に - -**Files:** -- Modify: `packages/frontend/src/app/page.tsx` -- Modify: `packages/frontend/src/app/page.test.tsx`(あれば) - -- [ ] **Step 1: テスト更新** - -fetch mock を `/api/projects` が `projects[]` を返すように調整し、「+ 新規プロジェクト」「既存を読み込む」の 2 ボタン、各プロジェクト行に「開く」「レジストリから外す」の UI テストを書く。 - -- [ ] **Step 2-4: TDD サイクル** - -実装: `fetchRegistryProjects()` で一覧取得、`unregisterProjectApi(id)` で外す、「既存を読み込む」で `ProjectImportDialog` を開く、「+ 新規プロジェクト」で `NewProjectDialog` を開く。 - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/page.tsx packages/frontend/src/app/page.test.tsx -git commit -m "refactor(frontend): トップページを registry 駆動に、インポートUIと新規プロジェクト UI を統合" -``` - ---- - -## Phase 6: AI Engine regression 修正 - -### Task 24: ai-engine の codebasePath シグネチャを `codebases[]` 対応に(in-scope 最小) - -**Files:** -- Modify: `packages/ai-engine/src/agent-runner.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/codebase-anchor.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/find-related-code.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/analyze-impact.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/extract-questions.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/server.test.ts`(テスト側のみ) - -- [ ] **Step 1: 失敗するテストを書く** - -各 agent の `.test.ts` で `codebasePath: '/x'` 引数を `codebase: { id: 'x', label: 'X', path: '/x' }` に書き換える。`agent-runner.test.ts` も同様に呼び出し側を更新。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/ai-engine test` -Expected: FAIL(旧シグネチャ) - -- [ ] **Step 3: 各 agent の入力シグネチャ変更** - -`codebase-anchor.ts` 例: - -```ts -import type { Codebase } from '@tally/core'; - -export interface CodebaseAnchorInput { - codebase: Codebase; - // ... 既存 -} - -export async function runCodebaseAnchor(input: CodebaseAnchorInput) { - const cwd = input.codebase.path; - // ... 既存ロジック、input.codebasePath を input.codebase.path に置換 -} -``` - -同様に他エージェントも `codebase: Codebase` 引数へ変更。呼び出し箇所(`agent-runner.ts`)で `projectMeta.codebases[0]` を受け取って渡すデフォルト処理を入れる(codebases 0 件のケースは呼び出し側で事前に弾く前提)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/ai-engine test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/ai-engine/src -git commit -m "refactor(ai-engine): agents を codebase: Codebase 引数に変更 - -単一 codebasePath 前提を撤廃し、呼び出し側が Codebase オブジェクトを渡すシグネチャに。 -複数 codebase を跨いだ探索は別 spec のスコープ(out-of-scope)。" -``` - ---- - -### Task 25: ai-actions ボタン群を codebases[] 対応に - -**Files:** -- Modify: `packages/frontend/src/components/ai-actions/codebase-agent-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/find-related-code-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/analyze-impact-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/extract-questions-button.tsx`(必要なら) -- Modify: `packages/frontend/src/components/ai-actions/graph-agent-button.tsx` - -- [ ] **Step 1: 失敗するテストを書く(1 ボタンずつ)** - -codebase-agent-button.test.tsx に: - -```tsx -it('codebases が 0 件なら disabled + tooltip', () => { - // useCanvasStore を mock して projectMeta.codebases: [] を返す - // ボタンが disabled であることと、ツールチップ文言を検証 -}); - -it('codebases が 1 件のみならそれを使う', () => { - // 単一 codebase を渡して agent 呼び出し引数を検証 -}); - -it('codebases が 2 件以上なら選択 UI(ドロップダウン)を表示', () => { - // ドロップダウン選択後にボタン押下、正しい codebase が渡る -}); -``` - -- [ ] **Step 2-4: TDD サイクル** - -実装: 各ボタンで `projectMeta.codebases` を参照し、0 件 → disabled、1 件 → そのまま、2 件以上 → 選択 UI を出す。選択後の codebase を agent 呼び出しに渡す。 - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/ai-actions/ -git commit -m "feat(frontend/ai-actions): codebases[] 対応(0件disabled, 1件自動, 複数件選択UI)" -``` - ---- - -## Phase 7: ドキュメント & フィクスチャ - -### Task 26: ADR-0008 / 0009 / 0010 を書く - -**Files:** -- Create: `docs/adr/0008-project-independent-from-repo.md` -- Create: `docs/adr/0009-project-registry.md` -- Create: `docs/adr/0010-multiple-codebases.md` -- Modify: `docs/adr/0003-git-managed-yaml.md`(Superseded に) - -- [ ] **Step 1: ADR 執筆** - -既存 ADR (`docs/adr/0001-sysml-alignment.md` 等) の形式に合わせて書く: - -```markdown -# ADR-0008: プロジェクトをリポジトリから切り離す - -- **日付**: 2026-04-21 -- **ステータス**: Accepted -- **Supersedes**: ADR-0003 - -## コンテキスト -(spec の「背景」から抜粋・整形) - -## 決定 -プロジェクト = 任意のディレクトリ。`.tally/` サブディレクトリ規約を廃止。 -プロジェクトディレクトリ直下に `project.yaml` / `nodes/` / `edges/` / `chats/` を置く。 - -## 影響 -- 暗黙スキャン(ghq / TALLY_WORKSPACE)全廃(ADR-0009 に続く) -- 1 プロジェクト = 1 リポジトリ前提の解消(ADR-0010 に続く) - -## 参考 -- spec: `docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md` -``` - -ADR-0009 / 0010 も同様に spec の該当セクションから起こす。 - -ADR-0003 のステータスを `Superseded by ADR-0008` に変更し、冒頭に注記を追加。 - -- [ ] **Step 2: コミット** - -```bash -git add docs/adr/0003-git-managed-yaml.md docs/adr/0008-*.md docs/adr/0009-*.md docs/adr/0010-*.md -git commit -m "docs(adr): ADR-0008/0009/0010 追加、ADR-0003 を Superseded に" -``` - ---- - -### Task 27: examples/sample-project を刷新 - -**Files:** -- Delete: `examples/sample-project/.tally/` 以下全て -- Create: `examples/sample-project/project.yaml`, `examples/sample-project/nodes/*`, `examples/sample-project/edges/edges.yaml` - -- [ ] **Step 1: 現在の `.tally/` 中身を確認** - -```bash -ls examples/sample-project/.tally/ -cat examples/sample-project/.tally/project.yaml -``` - -- [ ] **Step 2: `.tally/` 内容を `examples/sample-project/` 直下に移動** - -```bash -mv examples/sample-project/.tally/project.yaml examples/sample-project/ -mv examples/sample-project/.tally/nodes examples/sample-project/ -mv examples/sample-project/.tally/edges examples/sample-project/ -rmdir examples/sample-project/.tally -``` - -- [ ] **Step 3: project.yaml を新スキーマに書き換え** - -`examples/sample-project/project.yaml`: - -```yaml -id: proj-sample-0001 -name: TaskFlow 招待機能追加 -description: SaaS にチーム招待機能を追加するプロジェクト -codebases: - - id: backend - label: TaskFlow API - path: ../taskflow-backend -createdAt: 2026-04-18T10:00:00Z -updatedAt: 2026-04-21T00:00:00Z -``` - -- [ ] **Step 4: 既存の coderef ノードに `codebaseId: backend` を追加** - -```bash -# coderef 系の yaml を検出して手動編集 -grep -l "^type: coderef" examples/sample-project/nodes/*.yaml -# 各ファイルに codebaseId: backend を追加 -``` - -- [ ] **Step 5: 動作確認 + コミット** - -```bash -pnpm -F @tally/storage test # 全体の test を通す -git add examples/sample-project -git commit -m "refactor(examples): sample-project を codebases[] スキーマに移行" -``` - ---- - -### Task 28: CLAUDE.md / README.md / docs 更新 - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `README.md` -- Modify: `docs/03-architecture.md` など(`.tally/` 言及を持つものすべて) - -- [ ] **Step 1: `.tally/` 参照を全 grep** - -```bash -grep -rn "\.tally/" CLAUDE.md README.md docs/ -``` - -- [ ] **Step 2: 各ファイルを更新** - -- `.tally/` → 「プロジェクトディレクトリ」または具体例 `~/.local/share/tally/projects//` -- `TALLY_WORKSPACE` → `TALLY_HOME` -- ghq 連携記述 → 削除、レジストリ + フォルダピッカーの説明に置換 -- ADR-0003 リンク → Superseded の注釈と ADR-0008 への参照 - -- [ ] **Step 3: コミット** - -```bash -git add CLAUDE.md README.md docs/ -git commit -m "docs: .tally/ 規約廃止を反映、registry ベースの利用フローに更新" -``` - ---- - -### Task 29: 全体 E2E 確認 - -- [ ] **Step 1: パッケージごとに test 実行** - -```bash -pnpm -r test -``` - -Expected: すべて PASS - -- [ ] **Step 2: typecheck / lint** - -```bash -pnpm -r typecheck -pnpm -r lint -``` - -Expected: エラーなし - -- [ ] **Step 3: dev 起動して手動確認** - -```bash -pnpm dev -``` - -ブラウザで: -1. `+ 新規プロジェクト` → FolderBrowserDialog で任意の空 dir を選択 → codebase 追加(任意)→ 作成 -2. `既存を読み込む` → FolderBrowserDialog → import -3. トップページでプロジェクト一覧表示、「開く」「レジストリから外す」動作 -4. プロジェクト内で coderef ノード作成、AI ボタンが codebases[] を参照 - -- [ ] **Step 4: 最終コミット(残件があれば)** - -```bash -# 手動確認で発見した fix があればコミット -git commit -m "fix: E2E 確認で発見した問題を修正" -``` - ---- - -## 実装順序の要約 - -1. Phase 1 (Task 1-2): core 型刷新 — 全体のコンパイル起点 -2. Phase 2 (Task 3-10): storage 層 — registry / project-dir / init-project / ストア刷新 -3. Phase 3 (Task 11-15): バックエンド API — fs 系、projects 系 -4. Phase 4 (Task 16-17): frontend lib — api / store -5. Phase 5 (Task 18-23): frontend dialog & page — FolderBrowser / NewProject / Import / Settings / top page -6. Phase 6 (Task 24-25): ai-engine regression fix -7. Phase 7 (Task 26-29): docs / examples / E2E - -途中で型エラーが別パッケージに波及するため、Phase 1 → Phase 2 の境目と、Phase 4 / 5 の境目で `pnpm -r typecheck` をかけて穴を埋める。 - ---- - -## Self-Review 結果 - -- **spec カバレッジ**: spec 各セクション(データモデル / レジストリ / フォルダブラウザ / 変更&削除リスト / テスト戦略 / ADR / スコープ境界)はすべて Task に対応 -- **placeholder**: 未定義関数・TBD・"同様" 参照はなし。一部 UI モック操作の詳細は実装時に `testid` を決めて埋めるとしている(Task 19, 21, 22)— 許容範囲 -- **型整合**: `Codebase` / `ProjectMeta` / `FsListResult` / `RegistryEntry` のフィールド名は全 Task で一貫 -- **coderef vs code**: 実コードの型名 `coderef` を計画内で統一 diff --git a/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md b/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md deleted file mode 100644 index 5563635..0000000 --- a/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md +++ /dev/null @@ -1,479 +0,0 @@ -# プロジェクトストレージ再設計 - -- **日付**: 2026-04-21 -- **ステータス**: Design (未実装) -- **関連 ADR**: ADR-0008 / ADR-0009 / ADR-0010(新規)、ADR-0003(Supersede) - -## 背景 - -現状の Tally は ADR-0003 に基づき、プロジェクトを対象リポジトリ直下の `.tally/` ディレクトリに YAML ファイル群として保存している。発見は `ghq list -p` と `TALLY_WORKSPACE` 環境変数によるスキャンで行う。 - -この設計は「1 プロジェクト = 1 リポジトリ」を前提としているが、実際のユースケースではフロントエンドとバックエンドが別リポジトリに分かれる構成が頻出する。現状では横断プロジェクトを素直に表現できない。 - -加えて、個人の思考ログ・初期検証段階のアイデアなど「まだリポジトリに紐付けたくない」プロジェクトの置き場所がない。 - -## 目標 - -1. プロジェクトをリポジトリから独立した第一級の存在として扱う -2. 0 件以上のコードベースを 1 プロジェクトから参照できる(未紐付けのアイデアから多リポジトリ横断プロジェクトまでを同一モデルで扱う) -3. 保存先をユーザーが任意に選べる(デフォルトは `~/.local/share/tally/projects/`) -4. 発見ロジックを明示レジストリに統一し、暗黙スキャンを廃止する -5. フォルダ選択ダイアログ(バックエンド駆動)でプロジェクト作成・インポートを行う - -後方互換は維持しない。既存の `.tally/` 規約・`TALLY_WORKSPACE`・ghq 連携はすべて廃止し、ADR-0003 を Supersede する。 - -## アーキテクチャ概要 - -**コアコンセプト**: プロジェクト = 任意のディレクトリ。そのディレクトリ直下に `project.yaml` / `nodes/` / `edges/` / `chats/` を配置する。`.tally/` というサブディレクトリ規約は廃止する。 - -**5 本の柱**: - -1. プロジェクト = ディレクトリ(`.tally/` 命名なし) -2. 場所は自由(デフォルト `~/.local/share/tally/projects//` を提案、ユーザーが最終決定) -3. レジストリで管理(`~/.local/share/tally/registry.yaml`) -4. 複数コードベース(`codebases[]`、code ノードは `codebaseId` 参照) -5. フォルダピッカー必須(バックエンド駆動ブラウザ) - -## データモデル - -### Project 型 - -```ts -interface Project { - id: string; // nanoid (proj_xxxxx) - name: string; - description?: string; - codebases: Codebase[]; // 0 件以上(初期検討用に空配列を許容) - createdAt: string; - updatedAt: string; -} - -interface Codebase { - id: string; // ユーザー指定 short id - label: string; // 表示名 - path: string; // 絶対パス -} -``` - -設計判断: -- `codebasePath: string` / `additionalCodebasePaths: string[]` は完全削除し、`codebases[]` に一元化 -- `codebases` は空配列を許容する。これにより「まだリポジトリを決めていないアイデア段階」のプロジェクトを自然に扱える -- code ノードが存在するときは最低 1 件の codebase が必要(整合性制約)。code ノードが無ければ codebases は 0 件でよい -- `primary` フラグは持たない。必要なら配列順で表現(先頭が主) -- `Codebase.id` は code ノードからの参照キー。人間可読必須 -- パスは絶対パス必須(マシン間持ち回りは別スコープ) - -codebases 0 件時の UI 挙動: -- code ノード追加系 UI(コード参照ボタン、AI の「関連コード探索」等)はすべて無効化し、ツールチップで「コードベースを追加してください」と表示 -- プロジェクト設定から後からいつでも codebase を追加できる - -### CodeNode 型 - -```ts -interface CodeNode { - id: string; - type: 'code'; - codebaseId: string; // 必須 - path: string; // codebase root からの相対パス - // ... 既存フィールド -} -``` - -- `codebaseId` 必須。古いスキーマのロードコードは存在させない -- `codebases[].id` に存在しない `codebaseId` を持つ code ノードは、ロード時に検出してエラー通知(自動削除はしない) - -### バリデーション規約 - -- `codebases` は空配列可(`code` ノードが存在する場合のみ最低 1 件必要) -- `codebases[].id` に重複 → プロジェクト更新拒否 -- `codebases[].id` は `/^[a-z][a-z0-9-]{0,31}$/` に制限(ファイルシステム安全な short ID) -- code ノード保存時、`codebaseId` がプロジェクト内に存在するか検証(失敗時はエラー、code ノードは作成しない) -- 既存 code ノードを持つプロジェクトから codebase を削除しようとした場合、その codebase を参照する code ノードがあるなら削除拒否(あるいは明示確認) - -### project.yaml 例 - -```yaml -id: proj_abc123 -name: TaskFlow 招待機能追加 -description: SaaS にチーム招待機能を追加するプロジェクト -codebases: - - id: frontend - label: TaskFlow Web - path: /Users/you/dev/github.com/acme/taskflow-web - - id: backend - label: TaskFlow API - path: /Users/you/dev/github.com/acme/taskflow-api -createdAt: 2026-04-21T10:00:00Z -updatedAt: 2026-04-21T10:00:00Z -``` - -### nodes/code-*.yaml 例 - -```yaml -id: code_invite_handler -type: code -codebaseId: backend -path: src/handlers/invite.ts -x: 420 -y: 180 -title: 招待ハンドラ -``` - -## レジストリ - -### ファイル配置 - -``` -$XDG_DATA_HOME/tally/ (省略時 ~/.local/share/tally/) -├── registry.yaml # 既知プロジェクト一覧 -└── projects/ # デフォルト作成先(固定ではない) - ├── taskflow-invite/ # ディレクトリ名はユーザーが決める(slug 提案) - │ ├── project.yaml # id は中身で管理(例: proj_abc123) - │ ├── nodes/ - │ ├── edges/ - │ └── chats/ - └── personal-thoughts/ ... -``` - -`projects/` 配下はデフォルトの置き場。ユーザーが別パスを選べばそちらに作られ、`projects/` には作られない。 - -### registry.yaml スキーマ - -```yaml -version: 1 -projects: - - id: proj_abc123 - path: /Users/you/.local/share/tally/projects/taskflow-invite - lastOpenedAt: 2026-04-21T10:00:00Z - - id: proj_xyz789 - path: /Users/you/dev/shared-specs/auth-migration - lastOpenedAt: 2026-04-20T15:00:00Z -``` - -ディレクトリ名とプロジェクト id は独立していることに注意(ユーザーは dir 名を自由に決められる)。 - -- `id` は project.yaml の id と必ず一致 -- `path` は絶対パス(プロジェクトディレクトリそのもの、`project.yaml` の親) -- `lastOpenedAt` は UI のソート用 -- `version` はスキーマ進化のため - -### 環境変数 - -- `TALLY_HOME`: レジストリとデフォルト projects/ の親ディレクトリ。省略時 `$XDG_DATA_HOME/tally` → `~/.local/share/tally` -- `TALLY_WORKSPACE` は廃止 -- ghq 連携は廃止 - -### API(`packages/storage/src/registry.ts`) - -```ts -export interface RegistryEntry { - id: string; - path: string; - lastOpenedAt: string; -} - -export interface Registry { - version: 1; - projects: RegistryEntry[]; -} - -export function resolveTallyHome(): string; -export function resolveRegistryPath(): string; -export function resolveDefaultProjectsRoot(): string; - -export async function loadRegistry(): Promise; -export async function saveRegistry(r: Registry): Promise; - -export async function listProjects(): Promise; // lastOpenedAt 降順 -export async function registerProject(entry: { id: string; path: string }): Promise; -export async function unregisterProject(id: string): Promise; -export async function touchProject(id: string): Promise; // lastOpenedAt 更新 -``` - -### 不整合処理 - -- path 先にディレクトリが無い: UI で「見つからない」状態表示 + 「再選択」or「レジストリから削除」を選ばせる。自動削除はしない -- path 先の project.yaml の id が registry と食い違う: エラー表示、ユーザーに修正させる -- id 重複がレジストリ内に存在: 後勝ち(warn ログ + 後発を採用)。作成時は衝突チェック後に新規 id を再生成 - -### 書き込みアトミシティ - -registry.yaml は temp file → rename で原子的に書く。プロジェクト内の YAML 群も同方式(既存 `yaml.ts` に atomicWriteFile ヘルパを揃える)。 - -## フォルダブラウザ - -### バックエンド API(Next.js Route Handlers) - -``` -GET /api/fs/ls?path= -``` - -レスポンス: - -```ts -interface FsListResponse { - path: string; // 正規化された絶対パス - parent: string | null; // 1 つ上。ルート時は null - entries: FsEntry[]; // ディレクトリのみ - containsProjectYaml: boolean; // この dir が project.yaml を含むか -} - -interface FsEntry { - name: string; - path: string; - isHidden: boolean; // 先頭 "." 判定 - hasProjectYaml: boolean; // この子が project.yaml を含むか(インポート用ヒント) -} -``` - -仕様: -- `path` 未指定時は `os.homedir()` にフォールバック -- ディレクトリのみ返す(ファイル非表示) -- 隠しディレクトリは `isHidden: true` で返し、フロントでトグル表示 -- `~` / 環境変数展開はサーバ側で行わない(クライアントが絶対パスを明示) -- エラーは HTTP 400/403/404 を使い分け(権限・不在・不正パス) - -バリデーション(path パラメータ): -- 絶対パスであること(`path.isAbsolute()` チェック)。相対パスは 400 -- `path.resolve()` 後に再度絶対パス性と正規化(`..` 解決後のパス)を検証 -- 正規化後パスと受信パスが乖離する(= `..` 経由で上位に抜けようとした)場合、受信パスをそのまま受理した結果ではなく正規化後のパスで処理する(path traversal 対策) -- 明示的にアクセス拒否するディレクトリは設けない(ローカル dev tool 前提のため)が、`/proc` `/sys` など読み取り困難な領域は 200 で空 entries に丸める - -セキュリティ: -- Tally は localhost 限定 dev tool 前提。API は任意 `path` を読むため `127.0.0.1` バインド・CORS 無効を維持 -- symlink は辿る(ユーザー期待値)。`/proc`, `/sys` などシステムパスは深入りしない(200 で空 entries) - -``` -POST /api/fs/mkdir -{ "path": string, "name": string } -``` - -- `path/name` で安全に mkdir -- 既存時は 409 -- `name` は path separator・`.`/`..` を拒否、空文字列も拒否 -- `path` は `GET /api/fs/ls` と同じバリデーション(絶対パス + `path.resolve` 後再検証) -- `path.join(path, name)` 後、正規化したパスが元の `path` の配下にあることを再確認(path traversal 二重防御) - -### `FolderBrowserDialog` コンポーネント - -Props: - -```ts -interface FolderBrowserDialogProps { - open: boolean; - initialPath?: string; // 省略時 ~ - purpose: 'create-project' | 'import-project' | 'add-codebase'; - onConfirm: (absolutePath: string) => void; - onClose: () => void; -} -``` - -- `purpose` は表示テキスト・確定ボタンラベル・確定条件を切り替える。**全ケースで返り値は「確定した絶対パス 1 本」** という一貫した契約を持つ - - create-project: 選択 dir を**プロジェクトルートそのもの**として確定。ダイアログ自身は dir の中身を制約せず返り値としてパスを返すだけ(空・非空の判定は呼び出し側 = NewProjectDialog 側で行う。後述のバリデーション参照)。呼び出し側はこのパスに直接 `project.yaml` / `nodes/` / `edges/` を書き込む - - import-project: 選択 dir に `project.yaml` 必須(無ければ disabled)。そのままプロジェクトルートとして登録 - - add-codebase: 選択 dir をそのまま codebase path に -- **ディレクトリ名とプロジェクト id は独立**。id は内部識別子、ディレクトリ名は人間が好きに決める(例:`acme-taskflow-invite/`)。レジストリが両者を紐付ける -- UI ロジックは共通 - -### UI レイアウト(概略) - -``` -┌─────────────────────────────────────────────┐ -│ 保存先フォルダを選択 │ -├─────────────────────────────────────────────┤ -│ [ /Users/you/dev/acme ] [ ↑ 親 ] │ -│ ───────────────────────────────────────────│ -│ 📁 taskflow-web │ -│ 📁 taskflow-api (project.yaml あり)│ -│ 📁 docs │ -│ ... │ -│ [☐] 隠しフォルダを表示 │ -├─────────────────────────────────────────────┤ -│ [+ 新規フォルダ] [キャンセル] [選択] │ -└─────────────────────────────────────────────┘ -``` - -- パンくずはクリッカブル(各階層へジャンプ) -- テキスト入力でパス直打ち + Enter で移動(パワーユーザー向け) -- エントリクリックで潜る -- 子が project.yaml を持つ場合はバッジ表示 -- 「新規フォルダ」は name 入力 → POST /api/fs/mkdir → 作成した dir に自動で移動 -- キーボード: ↑↓ で選択、Enter で潜る、Cmd+Enter で確定 - -### NewProjectDialog 刷新 - -フィールド: -- プロジェクト名(必須) -- 説明(任意) -- プロジェクトルート(既定 `/projects/<プロジェクト名をslug化した候補>/`、「フォルダを変更」で FolderBrowserDialog) -- codebases[](任意、0 件可。「+ 追加」で FolderBrowserDialog → short id / label 入力) - -プロジェクトルートの決定ルール: -- 作成時点で id を先行生成するのではなく、**ユーザーが確定したディレクトリそのもの** をルートとする。id はレジストリ登録時に nanoid で生成し、project.yaml に記録するだけ -- デフォルト候補パスは名前入力に応じてリアルタイムで更新される(例:名前「TaskFlow 招待機能」→ `/projects/taskflow-invite/`)。slug 衝突時はサフィックスを付与 -- デフォルト候補 dir が既に存在する場合、ユーザーに警告を表示し、「別名にする」or「フォルダを変更」を促す -- 「フォルダを変更」でユーザーが指定したパスは、**指定 dir そのものがプロジェクトルートになる**(その中にサブディレクトリを勝手に作らない) - -バリデーション(NewProjectDialog 側で実施、FolderBrowserDialog 確定後に呼び出し側がチェック): -- 名前必須 -- `codebases[].id` 重複不可 -- プロジェクトルートのパスは以下の 4 ケースに分類して判定する: - -| 選択された dir の状態 | 扱い | UI | -|---|---|---| -| 存在しないパス(親ディレクトリは存在する) | **許可** | 作成時に mkdir | -| 既存の空 dir | **許可** | そのまま使う | -| 既存 dir + `project.yaml` を含む | **拒否** | 「既存プロジェクト。インポートする?」と ProjectImportDialog への誘導 | -| 既存 dir + 非空かつ `project.yaml` なし | **拒否** | 「ディレクトリは空ではありません」と警告し disabled | -| 親ディレクトリも存在しない | **拒否** | 「親ディレクトリが存在しません」と disabled | - -### ProjectImportDialog(新規) - -- FolderBrowserDialog(purpose: 'import-project') -- `project.yaml` を含む dir 選択 → registry に登録 -- 重複 id は検出してエラー(別のプロジェクトと id 衝突した旨を表示) - -### トップページ刷新 - -- `fetchWorkspaceCandidates()` 削除 -- `fetchRegistryProjects()` に置換、lastOpenedAt 降順で一覧 -- 各行に「開く」「レジストリから外す」「dir をエクスプローラで開く」 -- 上部に「+ 新規プロジェクト」「既存を読み込む」の 2 アクション - -## 変更対象・削除対象 - -### 削除 - -| パス | 理由 | -|---|---| -| `packages/storage/src/project-resolver.ts` | ghq/workspace scan 廃止、registry に置換 | -| `packages/storage/src/project-resolver.test.ts` | 上記に伴い | -| `packages/storage/src/index.ts` の `discoverProjects` / `listWorkspaceCandidates` / `resolveProjectById` / `loadProjectById` / `ProjectHandle` / `WorkspaceCandidate` export | 旧発見モデル廃止 | -| `packages/storage/src/index.ts` の `resolveTallyPaths` / `TallyPaths` export | `.tally/` 規約廃止(`project-dir.ts` に置換) | -| `packages/frontend/src/app/api/workspace-candidates/route.ts` | candidates API 廃止 | -| `packages/frontend/src/lib/api.ts` の `fetchWorkspaceCandidates`, `WorkspaceCandidate` | candidates モデル廃止 | -| `NewProjectDialog` の candidates UI 一式 | 刷新 | -| 環境変数 `TALLY_WORKSPACE` 参照箇所すべて | 廃止 | -| `.tally/` 規約(コード・ドキュメント・examples・テストフィクスチャ) | プロジェクト dir 名の規約を外す | -| `Project` / `ProjectMeta` の `codebasePath` / `additionalCodebasePaths` フィールド(`packages/core/src/schema.ts`)とその依存テスト | `codebases[]` に統合 | - -### 新規 - -| パス | 役割 | -|---|---| -| `packages/storage/src/registry.ts` | Registry CRUD | -| `packages/storage/src/registry.test.ts` | 単体テスト | -| `packages/storage/src/project-dir.ts` | projectDir からの paths 解決 | -| `packages/frontend/src/app/api/fs/ls/route.ts` | ディレクトリ一覧 API | -| `packages/frontend/src/app/api/fs/mkdir/route.ts` | 新規フォルダ作成 API | -| `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` | フォルダブラウザモーダル | -| `packages/frontend/src/components/dialog/project-import-dialog.tsx` | インポート用 | -| `docs/adr/0008-project-independent-from-repo.md` | ADR-0003 を Supersede | -| `docs/adr/0009-project-registry.md` | レジストリ機構 | -| `docs/adr/0010-multiple-codebases.md` | codebases[] モデル | - -### 変更 - -| パス | 変更 | -|---|---| -| `packages/core/src/schema.ts` | Project / ProjectMeta / Codebase / CodeNode 型刷新(`codebasePath`・`additionalCodebasePaths` 削除、`codebases: Codebase[]` 追加、code ノードに `codebaseId` 追加) | -| `packages/core/src/schema.test.ts` | 上記に追従 | -| `packages/storage/src/paths.ts` | registry 対応、`.tally/` サブディレクトリ廃止 | -| `packages/storage/src/init-project.ts` | registry 登録追加、codebases 0 件可 | -| `packages/storage/src/project-store.ts` | workspaceRoot → projectDir rename、codebases 対応 | -| `packages/frontend/src/components/dialog/new-project-dialog.tsx` | 全面刷新 | -| `packages/frontend/src/components/dialog/project-settings-dialog.tsx` + `.test.tsx`(新規作成) | 削除した旧版に代わり、`codebases[]` の追加・削除・並び替え・ラベル編集を提供。0 件運用(初期アイデア)から後付けで codebase を足せる受け皿。FolderBrowserDialog(purpose: 'add-codebase')を利用 | -| `packages/frontend/src/lib/api.ts` | registry 系クライアント追加、candidates 系削除、project meta CRUD を codebases[] 対応 | -| `packages/frontend/src/lib/store.ts` | `patchProjectMeta` 等を codebases[] 対応に刷新 | -| `packages/frontend/src/app/api/projects/[id]/route.ts` | codebases[] の読み書き、旧フィールド削除 | -| `packages/frontend/src/components/ai-actions/*` (codebase-agent-button, find-related-code-button, analyze-impact, extract-questions, graph-agent-button 等) | code 参照系 UI を codebases[] 前提に調整、codebases 0 件時は disabled | -| AI Engine (`agent-runner.ts`, `agents/codebase-anchor.ts`, `agents/find-related-code.ts`, `agents/analyze-impact.ts` ほか) | 単一 `codebasePath` 前提を捨て、対象 codebase を引数に取るよう改修(0 件時は呼び出し側が制御) | -| トップページ(projects 一覧) | registry 駆動 | -| `examples/sample-project/` | `.tally/` 廃止に伴い構造変更、旧フィクスチャ(taskflow-backend 参照など)を新 codebases[] モデルに書き換え | -| CLAUDE.md | `.tally/` 言及の除去、registry ベースに更新 | -| README.md | 起動方法・利用フロー更新 | -| `docs/adr/0003-git-managed-yaml.md` | ステータスを Superseded に更新、新 ADR へリンク | - -**波及範囲の注記**: 旧 `codebasePath` / `additionalCodebasePaths` を参照するテスト・ロジックは core / storage / frontend / ai-engine 横断で 27 ファイル以上に渡る。実装時は `packages/core` の型変更を起点にコンパイルエラーを潰す方式が安全。 - -## テスト戦略 - -### packages/storage - -- `registry.test.ts`: load/save ラウンドトリップ、重複 id、touch 順序、壊れた YAML のリカバリ -- `project-dir.test.ts`: projectDir から paths 生成の境界 -- `init-project.test.ts`: registry 登録と project dir 作成の原子性、codebases 0 件でも成功すること、project dir が非空かつ project.yaml 無しで拒否、id 衝突時の再生成 -- tmp dir fixture で XDG_DATA_HOME を差し替え、マシン環境に依存しない - -### packages/frontend - -- `folder-browser-dialog.test.tsx`: 潜る / 戻る / 新規作成 / 確定のユーザーフロー(Testing Library) -- `new-project-dialog.test.tsx`: 名前必須・プロジェクトルート空 dir 判定・codebases 0 件でも作成可のバリデーション -- `project-import-dialog.test.tsx`: project.yaml 有無で確定ボタン活性制御 -- API ルート(fs/ls, fs/mkdir)の単体テスト: symlink, 権限エラー, 存在しないパス, path traversal 防止 - -### E2E(Playwright が動く段階で) - -- 新規作成 → FolderBrowser → 作成 → トップに表示 → 開く → リロード後も残る -- 既存プロジェクトのインポート -- レジストリ path 消失後の UI 表示 - -### 廃棄 - -- 既存 `project-resolver.test.ts` -- ghq / TALLY_WORKSPACE を前提とした既存テスト - -## ADR 構成 - -1. **ADR-0008: プロジェクトをリポジトリから切り離す** - - コンテキスト: マルチレポ横断プロジェクト、repo に縛られない思考単位 - - 決定: プロジェクト = 任意のディレクトリ、`.tally/` 規約廃止 - - 影響: ADR-0003 Superseded - -2. **ADR-0009: プロジェクトレジストリによる発見** - - コンテキスト: 暗黙スキャンは予測不能 - - 決定: `~/.local/share/tally/registry.yaml` による明示レジストリのみ - - 影響: TALLY_WORKSPACE 廃止、ghq 連携削除 - -3. **ADR-0010: 複数 codebases の参照モデル** - - コンテキスト: 1 プロジェクト = 1 repo の破綻 - - 決定: `codebases[]`、code ノードは `codebaseId` 参照 - - 影響: AI エージェントの探索対象を「codebase を選択 / すべて」に拡張する後続作業 - -## スコープ境界 - -### AI Engine の in-scope / out-of-scope - -データモデル刷新に伴い AI Engine の関数群もシグネチャ変更が避けられないため、最低限の修正を in-scope に含める。新機能は別 spec に回す。 - -**in-scope(このspec で扱う、regression 防止の最低限修正)**: -- 単一 `codebasePath` への参照を `codebases[]` に置き換えるシグネチャ改修 -- 呼び出し側が対象 codebase(1 件)を引数で指定する形に変更 -- 既存の単一 codebase 機能が壊れない範囲でコンパイルを通す -- codebases 0 件時は呼び出し側(UI)で操作を無効化するため、engine 側は `codebases.length === 0` のケースを受け取らない前提でよい - -**out-of-scope(別 spec で扱う)**: -- 複数 codebases を跨いだ探索(cwd 切替、結果マージ、エージェント並行実行) -- ユーザーが「どの codebase を探索対象にするか」を選ぶ UI と API -- codebases 間のクロスリファレンス(backend の関数から frontend の呼び出し箇所を辿る等) - -### そのほかの非スコープ - -- Git 公開ワークフロー(registry 外部のチーム共有、プロジェクト dir の repo 化ヘルパ) -- マシン間移動(絶対パス持ち回り問題。将来 `Codebase.path` に変数展開や overlay を検討) -- レジストリの並行編集(現時点ではロック不要、将来必要なら fcntl lock) - -## 実装順序の目安 - -1. `packages/core` 型刷新 -2. `packages/storage/src/registry.ts` + テスト -3. `packages/storage/src/init-project.ts` / `project-store.ts` 刷新 -4. バックエンド API(fs/ls, fs/mkdir) -5. `FolderBrowserDialog` + 単体テスト -6. `NewProjectDialog` 刷新 -7. `ProjectImportDialog` -8. トップページ刷新 -9. examples / docs / CLAUDE.md / README.md 更新 -10. ADR 3 本 commit - -詳細な実装計画は別途 `writing-plans` スキルで作成する。 diff --git a/examples/sample-project/README.md b/examples/sample-project/README.md index 0a2417c..cf6162f 100644 --- a/examples/sample-project/README.md +++ b/examples/sample-project/README.md @@ -41,7 +41,7 @@ sample-project/ pnpm dev # ブラウザで以下を開く -# http://localhost:3000/projects/taskflow-invite +# http://localhost:3321/projects/taskflow-invite ``` ## 注意 diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 94adc43..9926a9e 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -228,6 +228,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'FRC integration', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -288,6 +289,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'P', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', }); @@ -436,7 +438,9 @@ describe('runAgent', () => { it('ingest-document: anchor 無しで起動し、tool_use を素通しする', async () => { const store = { getNode: vi.fn(), - getProjectMeta: vi.fn(), + // Task 15: agent-runner は mcpServers[] を取得するため毎ターン getProjectMeta を呼ぶ。 + // 空 (mcpServers なし) を返して既存挙動と同等にする。 + getProjectMeta: vi.fn().mockResolvedValue(null), addNode: vi.fn(), listNodes: vi.fn().mockResolvedValue([]), findRelatedNodes: vi.fn().mockResolvedValue([]), @@ -489,8 +493,85 @@ describe('runAgent', () => { expect(events.some((e) => e.type === 'error')).toBe(false); const toolUseEvents = events.filter((e) => e.type === 'tool_use'); expect(toolUseEvents.length).toBeGreaterThan(0); - // anchor 無しなので store.getNode / getProjectMeta は呼ばれない + // anchor 無しなので store.getNode は呼ばれない expect(store.getNode).not.toHaveBeenCalled(); - expect(store.getProjectMeta).not.toHaveBeenCalled(); + // Task 15: getProjectMeta は mcpServers[] を取るため必ず呼ばれる + expect(store.getProjectMeta).toHaveBeenCalled(); + }); + + describe('Task 15: agent-runner で buildMcpServers を共有', () => { + it('プロジェクト mcpServers[] を sdk.query に動的に渡す (url のみ、auth は SDK 任せ)', async () => { + const store = { + getNode: vi.fn().mockResolvedValue({ + id: 'uc-1', + type: 'usecase', + x: 0, + y: 0, + title: 'UC', + body: '', + }), + getProjectMeta: vi.fn().mockResolvedValue({ + id: 'p', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }), + addNode: vi.fn(), + listNodes: vi.fn().mockResolvedValue([]), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addEdge: vi.fn(), + } as unknown as ProjectStore; + + const querySpy = vi.fn(() => + (async function* () { + yield { + type: 'result', + subtype: 'success', + result: 'ok', + } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + + for await (const _ of runAgent({ + sdk, + store, + projectDir: '/ws', + req: { + type: 'start', + agent: 'extract-questions', + projectId: 'p', + input: { nodeId: 'uc-1' }, + }, + })) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'atlassian']), + ); + const atlassian = callArg.options?.mcpServers?.atlassian; + expect(atlassian?.url).toBe('https://t.test/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian?.headers).toBeUndefined(); + // agent 固有の allowedTools + 外部 MCP wildcard + expect(callArg.options?.allowedTools).toContain('mcp__atlassian__*'); + }); }); }); diff --git a/packages/ai-engine/src/agent-runner.ts b/packages/ai-engine/src/agent-runner.ts index 7dc10e9..353d66c 100644 --- a/packages/ai-engine/src/agent-runner.ts +++ b/packages/ai-engine/src/agent-runner.ts @@ -2,6 +2,7 @@ import type { AgentName } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { AGENT_REGISTRY } from './agents/registry'; +import { buildMcpServers } from './mcp/build-mcp-servers'; import type { AgentEvent, SdkMessageLike } from './stream'; import { sdkMessageToAgentEvent } from './stream'; import { buildTallyMcpServer } from './tools'; @@ -18,9 +19,27 @@ export interface StartRequest { // テスト時に mockSdk を差し込めるようにするため。 // SDK 実体のシグネチャは `query({ prompt, options })` なので、systemPrompt / mcpServers / // allowedTools / cwd / settingSources / permissionMode はすべて options 内に入れる必要がある。 +// Streaming input mode 用の最小 SDKUserMessage 形状 (実 SDK の SDKUserMessage を duck-type 化)。 +// MCP HTTP transport の OAuth 状態を turn 跨ぎで保持したい場合は、 +// 1 query に AsyncIterable を渡し続ける必要がある。 +export interface SdkUserMessageLike { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id?: string; +} + +// SDK Query は AsyncIterable + 任意の close() を持つハンドル。 +// 実 SDK の Query 型 (interrupt / setMcpServers / streamInput / close) のうち、 +// chat-runner が触るのは close のみなので最小化して受ける。 +export interface SdkQueryHandle extends AsyncIterable { + close?(): void; +} + export interface SdkLike { query(opts: { - prompt: string; + // 単発 (agent-runner) は文字列、chat (multi-turn) は AsyncIterable で push 流す。 + prompt: string | AsyncIterable; options?: { systemPrompt?: string; mcpServers?: Record; @@ -42,7 +61,7 @@ export interface SdkLike { // 解決に失敗するケースがある。明示的にシステムの claude CLI パスを渡すと回避できる。 pathToClaudeCodeExecutable?: string; }; - }): AsyncIterable; + }): SdkQueryHandle; } export interface RunAgentDeps { @@ -104,19 +123,36 @@ export async function* runAgent(deps: RunAgentDeps): AsyncGenerator input: parsed.data, }); try { + // Task 15: プロジェクト設定の mcpServers[] を毎ターン読み込み、buildMcpServers で + // Tally MCP と外部 MCP (Atlassian 等) を合成する。chat-runner と同じ utility を共有。 + // env 未設定時は throw → catch で error event に流す。 + const projectMeta = await store.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ + tallyMcp: mcp, + configs: externalConfigs, + }); + // built-in ツールは mcp__ プレフィックスを持たないもの (Read / Glob / Grep など)。 // options.tools = 実質的な built-in 使用可能リスト。[] を渡せば Bash/Edit/Write 等すべてオフ。 const builtInTools = def.allowedTools.filter((t) => !t.startsWith('mcp__')); + // agent 固有の allowedTools (Tally MCP の具体 tool 名 + built-in) に、外部 MCP の wildcard + // (mcp____*) を合流。tally の wildcard は agent 側に既に具体名で並んでいるので除外して dedup。 + const finalAllowedTools = [ + ...def.allowedTools, + ...externalAllowed.filter((t) => t !== 'mcp__tally__*'), + ]; + const iter = sdk.query({ prompt: prompt.userPrompt, options: { systemPrompt: prompt.systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, + mcpServers, // built-in ツールは registry で宣言した範囲のみ許可。 // これで find-related-code に Bash / Edit / Write 等が使われなくなる。 tools: builtInTools, - // MCP ツール (mcp__tally__*) も含めて自動承認する。 - allowedTools: def.allowedTools, + // MCP ツール (mcp__tally__* + 外部 MCP wildcard) を自動承認する。 + allowedTools: finalAllowedTools, // 承認リスト外は拒否。built-in 側は tools で絞っているので二重ガード。 permissionMode: 'dontAsk', // cwd は find-related-code のコード探索スコープ。未指定エージェントは SDK デフォルト。 diff --git a/packages/ai-engine/src/agents/find-related-code.test.ts b/packages/ai-engine/src/agents/find-related-code.test.ts index c87341a..df915f4 100644 --- a/packages/ai-engine/src/agents/find-related-code.test.ts +++ b/packages/ai-engine/src/agents/find-related-code.test.ts @@ -52,6 +52,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -106,6 +107,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -122,6 +124,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: filePath }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -136,6 +139,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: '../nonexistent-xyz' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/async-input.test.ts b/packages/ai-engine/src/async-input.test.ts new file mode 100644 index 0000000..aa009a2 --- /dev/null +++ b/packages/ai-engine/src/async-input.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { AsyncIterableInput } from './async-input'; + +describe('AsyncIterableInput', () => { + it('push 後に for-await で順番に取り出せる', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.push(2); + input.push(3); + input.close(); + const got: number[] = []; + for await (const v of input.iterable()) got.push(v); + expect(got).toEqual([1, 2, 3]); + }); + + it('iter が空の状態で next() を待ち、後の push で解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p = it.next(); + // 待機状態を確認: 即時 resolve しない + let resolved = false; + p.then(() => { + resolved = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(resolved).toBe(false); + input.push('hi'); + const r = await p; + expect(r).toEqual({ value: 'hi', done: false }); + }); + + it('close() で待機中の next() が done: true で解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p = it.next(); + input.close(); + const r = await p; + expect(r.done).toBe(true); + }); + + it('close 後の push は無視される', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.close(); + input.push(99); // 無視 + const got: number[] = []; + for await (const v of input.iterable()) got.push(v); + expect(got).toEqual([1]); + }); + + it('iterator.return() で残りの push が消費されず終了', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.push(2); + const it = input.iterable()[Symbol.asyncIterator](); + const r1 = await it.next(); + expect(r1.value).toBe(1); + if (it.return) { + const r2 = await it.return(); + expect(r2.done).toBe(true); + } + }); + + // CodeRabbit 指摘 (PR #18): 単一 waiter スロットだと 2 回連続で next() を呼んだとき + // 1 つ目の resolver が捨てられて Promise が永遠に未解決になる。FIFO キューで保持する + // ことで、push 順に正しく解決されることを確認する。 + it('next() を複数回先に呼んでから push しても、push 順に各 promise が解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p1 = it.next(); + const p2 = it.next(); + input.push(10); + input.push(20); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual({ value: 10, done: false }); + expect(r2).toEqual({ value: 20, done: false }); + }); + + it('next() を 2 回先に呼んで push を 1 回だけしても、未解決の Promise は close で done に倒れる', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p1 = it.next(); + const p2 = it.next(); + input.push(42); + const r1 = await p1; + expect(r1).toEqual({ value: 42, done: false }); + // p2 は未解決 + let p2Resolved = false; + p2.then(() => { + p2Resolved = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(p2Resolved).toBe(false); + // close で残りの waiter が done に倒れる + input.close(); + const r2 = await p2; + expect(r2.done).toBe(true); + }); +}); diff --git a/packages/ai-engine/src/async-input.ts b/packages/ai-engine/src/async-input.ts new file mode 100644 index 0000000..4973656 --- /dev/null +++ b/packages/ai-engine/src/async-input.ts @@ -0,0 +1,57 @@ +// SDK の query({ prompt: AsyncIterable }) に流す、 +// 後から push できる AsyncIterable 実装。 +// 1 chat thread = 1 long-lived sdk.query() を実現するため、user message を +// 任意のタイミングで投入し、close で iter を終わらせる。 +// +// 実装方針: バッファ + waiter キュー。consumer が next を複数回連続で呼んでも +// 各 promise が独立に保持される。AsyncIterator 仕様に沿うため waiter は +// 単一スロットではなく FIFO キューで持つ (consumer が並行で next() を呼ぶ +// ケースに耐える)。 +export class AsyncIterableInput { + private buf: T[] = []; + private waiters: Array<(r: IteratorResult) => void> = []; + private finished = false; + + push(value: T): void { + if (this.finished) return; + const w = this.waiters.shift(); + if (w) { + w({ value, done: false }); + return; + } + this.buf.push(value); + } + + close(): void { + if (this.finished) return; + this.finished = true; + while (this.waiters.length > 0) { + this.waiters.shift()?.({ value: undefined as never, done: true }); + } + } + + // SDK 等に渡す iter。同一インスタンスから複数回 [Symbol.asyncIterator] を取られる + // ことは想定しない (本パッケージでは 1 query に 1 input)。 + iterable(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: (): Promise> => { + if (this.buf.length > 0) { + const v = this.buf.shift() as T; + return Promise.resolve({ value: v, done: false }); + } + if (this.finished) { + return Promise.resolve({ value: undefined as never, done: true }); + } + return new Promise>((resolve) => { + this.waiters.push(resolve); + }); + }, + return: (): Promise> => { + this.close(); + return Promise.resolve({ value: undefined as never, done: true }); + }, + }), + }; + } +} diff --git a/packages/ai-engine/src/auth-detector.test.ts b/packages/ai-engine/src/auth-detector.test.ts new file mode 100644 index 0000000..fd3bf36 --- /dev/null +++ b/packages/ai-engine/src/auth-detector.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { extractAuthUrl, parseAuthToolName } from './auth-detector'; + +describe('parseAuthToolName', () => { + it('mcp__atlassian__authenticate を分解', () => { + expect(parseAuthToolName('mcp__atlassian__authenticate')).toEqual({ + mcpServerId: 'atlassian', + kind: 'authenticate', + }); + }); + + it('mcp__atlassian__complete_authentication を分解', () => { + expect(parseAuthToolName('mcp__atlassian__complete_authentication')).toEqual({ + mcpServerId: 'atlassian', + kind: 'complete_authentication', + }); + }); + + it('別 id でも動く (jira-cloud 等のハイフン許容)', () => { + expect(parseAuthToolName('mcp__jira-cloud__authenticate')).toEqual({ + mcpServerId: 'jira-cloud', + kind: 'authenticate', + }); + }); + + it('Tally 内部 MCP は match しない', () => { + expect(parseAuthToolName('mcp__tally__create_node')).toBeNull(); + }); + + it('別ツール名 (read_issue など) は match しない', () => { + expect(parseAuthToolName('mcp__atlassian__read_issue')).toBeNull(); + }); + + it('id が大文字を含むと reject', () => { + expect(parseAuthToolName('mcp__Atlassian__authenticate')).toBeNull(); + }); +}); + +describe('extractAuthUrl', () => { + it('SDK 標準 output 形式から URL を抽出', () => { + const out = `Ask the user to open this URL in their browser to authorize the atlassian MCP server: + +https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz + +Once they complete the flow, the server's tools will become available automatically.`; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', + ); + }); + + it('折り返し (`\\\\\\n` + 空白) も復元してから抽出', () => { + const out = + 'Ask the user: https://mcp.atlassian.com/v1/authorize?response_type=code&cli\\\n ent_id=abc&state=xyz_done'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz_done', + ); + }); + + it('クエリ文字列なしの URL は無視 (説明用 https://example.com 等)', () => { + expect(extractAuthUrl('See https://example.com for more info')).toBeNull(); + }); + + it('URL が無ければ null', () => { + expect(extractAuthUrl('no url here')).toBeNull(); + }); + + // CodeRabbit 指摘 (PR #18): 自然文末尾の句読点・括弧が URL に紛れて + // 認可リンクが壊れていた。末尾の `.,;:!?)` は剥がす。 + it('文末の句読点を URL に含めない (period)', () => { + const out = '認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz.'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', + ); + }); + + it('文末の閉じ括弧を URL に含めない', () => { + const out = '(認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz)'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', + ); + }); + + it('複数の末尾句読点も剥がす', () => { + const out = '行ってください: https://mcp.atlassian.com/v1/authorize?state=xyz!?'; + expect(extractAuthUrl(out)).toBe('https://mcp.atlassian.com/v1/authorize?state=xyz'); + }); +}); diff --git a/packages/ai-engine/src/auth-detector.ts b/packages/ai-engine/src/auth-detector.ts new file mode 100644 index 0000000..b81c142 --- /dev/null +++ b/packages/ai-engine/src/auth-detector.ts @@ -0,0 +1,37 @@ +// 外部 MCP の OAuth 2.1 認証フローを検出するヘルパ。 +// chat-runner が SDK から流れてくる tool_use / tool_result を walk しながら +// authenticate / complete_authentication をパターンで識別し、 +// auth_request ブロックに変換する判断材料を提供する。 + +const AUTH_TOOL_NAME_RE = /^mcp__([a-z][a-z0-9-]{0,31})__(authenticate|complete_authentication)$/; + +export interface AuthToolNameMatch { + mcpServerId: string; + kind: 'authenticate' | 'complete_authentication'; +} + +// `mcp____authenticate` / `mcp____complete_authentication` を分解する。 +// id 部は McpServerIdRegex (core schema 側) と整合: 先頭英小文字 + 英小文字/数字/ハイフン、32 字以内。 +export function parseAuthToolName(name: string): AuthToolNameMatch | null { + const m = name.match(AUTH_TOOL_NAME_RE); + if (!m) return null; + return { mcpServerId: m[1] ?? '', kind: m[2] as 'authenticate' | 'complete_authentication' }; +} + +// authenticate tool_result.output に含まれる OAuth 認可エンドポイントの URL を抽出する。 +// SDK の典型的な出力例: +// "Ask the user to open this URL ... https://mcp.atlassian.com/v1/authorize?..." +// URL は折り返されている (`\<改行>` でエスケープされていることもある) ので +// 復元してから正規表現を当てる。 +export function extractAuthUrl(output: string): string | null { + // SDK が 80 桁折返しで `\<改行 + 連続空白>` を入れる場合がある。これを潰す。 + const unfolded = output.replace(/\\\n\s*/g, ''); + // 最初に見つかった https://...?...&... を採用。query string を含むものに限定して + // 単なる説明用の URL (https://example.com 等) を引かないようにする。 + const urlRe = /https:\/\/[^\s)"'<>]+\?[^\s)"'<>]+/; + const m = unfolded.match(urlRe); + if (!m) return null; + // 自然文末尾の句読点 / 閉じ括弧が URL に紛れるのを除く (CodeRabbit 指摘 PR #18)。 + // 例: "...state=xyz." / "...state=xyz)" → 末尾の `.` `)` を落とす。 + return m[0].replace(/[).,;:!?]+$/u, ''); +} diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 0af071e..596812e 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import type { Node } from '@tally/core'; +import type { ChatMessage, Node } from '@tally/core'; import { newChatMessageId } from '@tally/core'; import { FileSystemChatStore, FileSystemProjectStore } from '@tally/storage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +11,27 @@ import type { SdkLike } from './agent-runner'; import { buildChatPrompt, ChatRunner, formatNodeForContext } from './chat-runner'; import type { ChatEvent, SdkMessageLike } from './stream'; +// long-lived Query 化に伴い prompt は AsyncIterable 型に変わった。 +// テスト側で「最初に push された user message の content」を読むためのヘルパ。 +// string で渡された場合 (互換) も同じ shape で扱えるようにする。 +function startCapturePromptText(prompt: unknown): { read: () => string } { + const captured = { value: '' }; + if (typeof prompt === 'string') { + captured.value = prompt; + } else if ( + prompt && + typeof (prompt as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function' + ) { + const it = (prompt as AsyncIterable<{ message?: { content?: string } }>)[ + Symbol.asyncIterator + ](); + it.next().then((r) => { + if (!r.done && r.value?.message?.content) captured.value = r.value.message.content; + }); + } + return { read: () => captured.value }; +} + describe('ChatRunner', () => { let root: string; @@ -21,6 +42,7 @@ describe('ChatRunner', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -193,10 +215,10 @@ describe('ChatRunner', () => { priority: 'must', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'assistant', @@ -220,6 +242,7 @@ describe('ChatRunner', () => { events.push(e); } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).toContain(''); expect(capturedPrompt).toContain(`id: ${target.id}`); expect(capturedPrompt).toContain('type: requirement'); @@ -272,10 +295,10 @@ describe('ChatRunner', () => { body: '', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -294,6 +317,7 @@ describe('ChatRunner', () => { // drain } + const capturedPrompt = promptCapture.read(); const histIdx = capturedPrompt.indexOf(''); const histEndIdx = capturedPrompt.indexOf(''); const ctxIdx = capturedPrompt.indexOf(''); @@ -324,10 +348,10 @@ describe('ChatRunner', () => { body: '', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -343,6 +367,7 @@ describe('ChatRunner', () => { for await (const _e of runner.runUserTurn('q', ['nonexistent', valid.id, 'also-gone'])) { // drain } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).toContain(''); expect(capturedPrompt).toContain(`id: ${valid.id}`); expect(capturedPrompt).not.toContain('id: nonexistent'); @@ -355,10 +380,10 @@ describe('ChatRunner', () => { const projectStore = new FileSystemProjectStore(root); const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -374,6 +399,7 @@ describe('ChatRunner', () => { for await (const _e of runner.runUserTurn('hello', [])) { // drain } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).not.toContain(''); // user 文字列自体は (履歴経由で) 必ず prompt に入る expect(capturedPrompt).toContain('hello'); @@ -474,3 +500,926 @@ describe('formatNodeForContext / buildChatPrompt', () => { expect(out.slice(curIdx)).not.toContain('過去質問'); }); }); + +describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('プロジェクト設定の mcpServers[] を sdk.query に動的に渡す (url のみ、auth は SDK 任せ)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'test-mcp', + name: 'T', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + expect(querySpy).toHaveBeenCalled(); + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'test-mcp']), + ); + const testMcp = callArg.options?.mcpServers?.['test-mcp']; + expect(testMcp?.url).toBe('https://t.test/mcp'); + expect(testMcp?.headers).toBeUndefined(); + expect(callArg.options?.allowedTools).toContain('mcp__tally__*'); + expect(callArg.options?.allowedTools).toContain('mcp__test-mcp__*'); + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcpServers[] が空配列なら tally のみ (退行なし)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record; allowedTools?: string[] }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual(['tally']); + expect(callArg.options?.allowedTools).toEqual(['mcp__tally__*']); + + rmSync(root, { recursive: true, force: true }); + }); + + it('OAuth 採用後: SDK 設定に Authorization header は付かない (auth は MCP/SDK 任せ)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://api.atlassian.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record }; + }; + const atlassian = callArg.options?.mcpServers?.atlassian; + expect(atlassian?.url).toBe('https://api.atlassian.test/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian?.headers).toBeUndefined(); + + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('外部 MCP の tool_use を source=external で永続化、chat_tool_external_use event を emit', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12a-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + id: 'atlassian-tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'atlassian-tu-1', + content: [{ type: 'text', text: '{"summary":"Epic title"}' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('@JIRA EPIC-1')) events.push(e); + + const useEvent = events.find((e) => e.type === 'chat_tool_external_use'); + expect(useEvent).toBeDefined(); + if (useEvent && useEvent.type === 'chat_tool_external_use') { + expect(useEvent.toolUseId).toBe('atlassian-tu-1'); + expect(useEvent.name).toBe('mcp__atlassian__jira_get_issue'); + } + const resultEvent = events.find((e) => e.type === 'chat_tool_external_result'); + expect(resultEvent).toBeDefined(); + if (resultEvent && resultEvent.type === 'chat_tool_external_result') { + expect(resultEvent.toolUseId).toBe('atlassian-tu-1'); + expect(resultEvent.ok).toBe(true); + expect(resultEvent.output).toContain('Epic title'); + } + + const reloaded = await chatStore.getChat(thread.id); + const asstMsg = reloaded?.messages.find((m) => m.role === 'assistant'); + const toolUse = asstMsg?.blocks.find((b) => b.type === 'tool_use'); + expect(toolUse).toBeDefined(); + if (toolUse?.type === 'tool_use') { + expect(toolUse.source).toBe('external'); + expect(toolUse.name).toBe('mcp__atlassian__jira_get_issue'); + expect(toolUse.approval).toBeUndefined(); + } + const toolResult = asstMsg?.blocks.find((b) => b.type === 'tool_result'); + expect(toolResult).toBeDefined(); + if (toolResult?.type === 'tool_result') { + expect(toolResult.ok).toBe(true); + expect(toolResult.output).toContain('Epic title'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcp__tally__ で始まる tool_use は無視 (intercept 経路で処理されるため)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '作ります' }, + { + type: 'tool_use', + id: 'tally-tu', + name: 'mcp__tally__create_node', + input: {}, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); + + rmSync(root, { recursive: true, force: true }); + }); + + it('tool_result output が 4KB 超えると永続化時に truncate、event は full (Task 13)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const bigOutput = 'X'.repeat(10_000); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'big-1', + content: [{ type: 'text', text: bigOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + // event はフル + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.output.length).toBe(10_000); + } + + // YAML 永続化は truncate + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + expect(tr).toBeDefined(); + if (tr?.type === 'tool_result') { + expect(tr.output.length).toBeLessThanOrEqual(4200); + expect(tr.output).toContain('(truncated'); + expect(tr.output).toContain('10000'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('tool_result output が 4KB 以下なら truncate しない', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const smallOutput = 'small result'; + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'small-1', + content: [{ type: 'text', text: smallOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('q')) { + /* drain */ + } + + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + if (tr?.type === 'tool_result') { + expect(tr.output).toBe(smallOutput); + expect(tr.output).not.toContain('truncated'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('外部 tool_result が is_error=true なら ok=false で記録', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'err-tu', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'BOGUS' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'err-tu', + content: [{ type: 'text', text: '404 not found' }], + is_error: true, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.ok).toBe(false); + expect(evt.output).toContain('404'); + } + + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe('buildChatPrompt — tool_use/tool_result replay (Task 14, T4 fix)', () => { + it('過去 turn の text + tool_use + tool_result が conversation_history に含まれる', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '@JIRA EPIC-1 を読んで' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { key: 'EPIC-1' }, + source: 'external', + }, + { type: 'tool_result', toolUseId: 'tu-1', ok: true, output: '{"summary":"Epic X"}' }, + { type: 'text', text: '読みました。Epic X です' }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: '続けて子チケット STORY-42 を読んで' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + + // 過去 turn の Jira 内容が prompt に含まれる (T4 fix の核) + expect(prompt).toContain('Epic X'); + expect(prompt).toContain('mcp__atlassian__jira_get_issue'); + expect(prompt).toContain('source="external"'); + // 直近 user message は current_user_message として独立 + expect(prompt).toContain(''); + expect(prompt).toContain('STORY-42'); + // tool_use / tool_result タグが正しく出る + expect(prompt).toContain(' { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '作って' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + approval: 'approved', + }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'next' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + expect(prompt).toContain('mcp__tally__create_node'); + expect(prompt).not.toContain('source="external"'); + expect(prompt).not.toContain('source="internal"'); + }); + + it('blocks が空の message は省く (履歴前段の空 assistant 想定)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a-empty', + role: 'assistant', + blocks: [], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'continue' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + // 空 assistant は省かれる + const messageOpens = prompt.match(//g) ?? []; + expect(messageOpens.length).toBe(0); + // user の "hello" は履歴に残る + expect(prompt).toContain('hello'); + expect(prompt).toContain('continue'); + }); + + it('過去 turn が無く current user のみのケース (初回 turn)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '初回' }], + createdAt: '2026-04-24T00:00:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + expect(prompt).not.toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('初回'); + }); +}); + +// 外部 MCP の OAuth 2.1 フロー: authenticate / complete_authentication tool_use を +// 検出して auth_request ブロックに変換する経路の検証。raw な tool_use/tool_result が +// チャット履歴に並ばず、UI が描画する auth_request 1 等地ブロックだけ残る。 +describe('ChatRunner — auth_request 変換 (OAuth 2.1)', () => { + async function setup() { + const root = mkdtempSync(path.join(tmpdir(), 'tally-chat-auth-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'My Atlassian', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + return { root, chatStore, projectStore, thread }; + } + + function makeAuthSdk(authUrl: string): SdkLike { + return { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '認証フローを開始します' }, + { + type: 'tool_use', + id: 'auth-tu-1', + name: 'mcp__atlassian__authenticate', + input: {}, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-1', + content: [{ type: 'text', text: `Open: ${authUrl}` }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + } + + it('authenticate: tool_use/tool_result を消化し、auth_request{pending} ブロック + chat_auth_request event を出す', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + const runner = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('jira を読んで')) events.push(e); + + // raw な tool_use / tool_result event は出ない (auth は auth_request 1 等地) + expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); + expect(events.find((e) => e.type === 'chat_tool_external_result')).toBeUndefined(); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.mcpServerId).toBe('atlassian'); + expect(authEvt.mcpServerLabel).toBe('My Atlassian'); + expect(authEvt.authUrl).toBe(authUrl); + expect(authEvt.status).toBe('pending'); + } + + // 永続化: assistant message に auth_request ブロックがあって、tool_use/tool_result は無い + const reloaded = await chatStore.getChat(thread.id); + const assistant = reloaded?.messages.find((m) => m.role === 'assistant'); + const blocks = assistant?.blocks ?? []; + const hasRawToolUse = blocks.some( + (b) => b.type === 'tool_use' && b.name.includes('authenticate'), + ); + expect(hasRawToolUse).toBe(false); + const authBlock = blocks.find((b) => b.type === 'auth_request'); + expect(authBlock).toBeDefined(); + if (authBlock && authBlock.type === 'auth_request') { + expect(authBlock.status).toBe('pending'); + expect(authBlock.authUrl).toBe(authUrl); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('complete_authentication 成功: 同 thread の最新 pending auth_request が completed に更新される', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + // turn 1: authenticate を流して pending auth_request を作る + const runner1 = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner1.runUserTurn('jira を読んで')) { + void _; + } + + // turn 2: complete_authentication が走るシナリオ + const sdk2: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'auth-tu-2', + name: 'mcp__atlassian__complete_authentication', + input: { url: 'http://localhost:54801/callback?code=xxx&state=xyz' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-2', + content: [{ type: 'text', text: 'authenticated' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'result', + subtype: 'success', + result: 'done', + } as unknown as SdkMessageLike; + })(), + }; + const runner2 = new ChatRunner({ + sdk: sdk2, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner2.runUserTurn( + '[OAuth callback] http://localhost:54801/callback?code=xxx&state=xyz', + )) + events.push(e); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.status).toBe('completed'); + expect(authEvt.mcpServerId).toBe('atlassian'); + } + + // 永続化: 元の pending auth_request ブロックが completed に書き換わっている + const reloaded = await chatStore.getChat(thread.id); + const allAuthBlocks = (reloaded?.messages ?? []).flatMap((m) => + m.blocks.filter((b) => b.type === 'auth_request'), + ); + // 同 server の auth_request は 1 個のままで、status が completed に変わっている + expect(allAuthBlocks).toHaveLength(1); + const ab = allAuthBlocks[0]; + if (ab && ab.type === 'auth_request') { + expect(ab.status).toBe('completed'); + expect(ab.authUrl).toBe(authUrl); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('complete_authentication 失敗 (ok=false): 最新 pending が failed + failureMessage 付きで更新', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + const runner1 = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner1.runUserTurn('jira を読んで')) { + void _; + } + + const sdk2: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'auth-tu-2', + name: 'mcp__atlassian__complete_authentication', + input: { url: 'http://localhost:54801/callback?code=bad' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-2', + content: [{ type: 'text', text: 'invalid_grant: state mismatch' }], + is_error: true, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'result', + subtype: 'success', + result: 'done', + } as unknown as SdkMessageLike; + })(), + }; + const runner2 = new ChatRunner({ + sdk: sdk2, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner2.runUserTurn('callback URL: ...')) events.push(e); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.status).toBe('failed'); + expect(authEvt.failureMessage).toContain('invalid_grant'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index dba1055..57fdd61 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -8,7 +8,11 @@ import { } from '@tally/core'; import type { ChatStore, ProjectStore } from '@tally/storage'; -import type { SdkLike } from './agent-runner'; +import type { SdkLike, SdkQueryHandle, SdkUserMessageLike } from './agent-runner'; +import { AsyncIterableInput } from './async-input'; +import { type AuthToolNameMatch, extractAuthUrl, parseAuthToolName } from './auth-detector'; +import { buildMcpServers } from './mcp/build-mcp-servers'; +import { redactMcpSecrets } from './mcp/redact'; import type { ChatEvent, SdkMessageLike } from './stream'; import { CreateEdgeInputSchema, createEdgeHandler } from './tools/create-edge'; import { CreateNodeInputSchema, createNodeHandler } from './tools/create-node'; @@ -25,9 +29,55 @@ export interface ChatRunnerDeps { threadId: string; } -// SDK の assistant message から抽出する block の単純化形。 -// MCP 経路に一本化したため tool_use は拾わないが、将来のデバッグ用に型定義は保持。 -type ExtractedBlock = { type: 'text'; text: string }; +// 外部 MCP の tool_result output を YAML に永続化するときの上限 (Task 13)。 +// 大規模 epic 取り込み等で 1 ターンに 500KB+ 来うるので、永続化は 4KB に切る。 +// メモリ内 (event) は full を流すので、UI セッション内では全文展開可能。 +// リロード後は truncated 版だけ見える (dogfooding には十分)。 +const TOOL_RESULT_PERSIST_LIMIT = 4096; + +function truncateForPersistence(output: string): string { + if (output.length <= TOOL_RESULT_PERSIST_LIMIT) return output; + const head = output.slice(0, TOOL_RESULT_PERSIST_LIMIT); + return `${head}\n... (truncated, ${output.length} chars total)`; +} + +// 最新の pending auth_request ブロックを探す (同一 mcpServerId 限定)。 +// thread.messages を末尾から走査し、最初に見つかった pending を返す。 +// 同一 server に対する直近の認証フローのみを更新対象にして、過去に completed/failed で +// 終わったブロックには触らない方針。 +function findLatestPendingAuthRequest( + messages: ChatMessage[], + mcpServerId: string, +): { + messageId: string; + blockIndex: number; + block: Extract; +} | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (!m) continue; + for (let j = m.blocks.length - 1; j >= 0; j--) { + const b = m.blocks[j]; + if ( + b && + b.type === 'auth_request' && + b.mcpServerId === mcpServerId && + b.status === 'pending' + ) { + return { messageId: m.id, blockIndex: j, block: b }; + } + } + } + return null; +} + +// SDK の assistant / user message から抽出する block の単純化形。 +// Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 +// 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 +type ExtractedBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; toolUseId: string; name: string; input: unknown } + | { type: 'tool_result'; toolUseId: string; ok: boolean; output: string }; // MCP ツール名と、そのハンドラ (承認必要かどうか) を束ねるエントリ。 // 承認必須のツールは create_* 系 (書き込み)、承認不要は find_related / list_by_type (読み取り)。 @@ -48,11 +98,40 @@ export interface ToolEntry { // - tool 呼び出しは createSdkMcpServer で登録した MCP 経由でのみ行う。 // MCP ハンドラ内で invokeInterceptedTool を呼び、pending → 承認 → 実行 → result を完結させる。 // SDK 側から見ると通常の tool 呼び出し (同期的に output を返す) に見える。 +// 1 user turn の間だけ生きる mutable state。 +// long-lived Query から流れてくる SDK メッセージを「今どの assistant message に紐付けるか」 +// 「どの queue に流すか」を解決するためのコンテキスト。 +// turn と turn の間 (= ユーザーが次の user message を送るまで) は null。 +interface TurnState { + assistantMsgId: string; + queue: EventQueue; + textBuffer: string[]; + // OAuth 認証フロー検出用 stash (tool_use 受信 → tool_result 到達時に auth_request に変換)。 + stashedAuthUses: Map; + // 外部 MCP の id → name の即引き map (label 表示用、turn ごとに固定で再構築しない)。 + externalConfigById: Map; +} + export class ChatRunner { private readonly deps: ChatRunnerDeps; // 承認待ちの Promise resolver。ui-toolUseId → (approved) => void。 private readonly pendingApprovals = new Map void>(); + // long-lived SDK Query。1 ChatRunner = 1 sdk.query() = 1 subprocess に固定して + // MCP HTTP transport の OAuth 状態 (PKCE / token) を turn 跨ぎで保持する。 + // null = まだ start していない。closed 状態になっても close() / 再 ensure で破棄して再開できる。 + private query: SdkQueryHandle | null = null; + private input: AsyncIterableInput | null = null; + private outputLoopDone: Promise | null = null; + private outputLoopFailed = false; + // 現在進行中の turn。runUserTurn の入口で set、出口で null。 + // MCP ハンドラと出力ループはここから assistantMsgId / queue を読む。 + private currentTurn: TurnState | null = null; + // ensureQuery が走った時に決まる、long-lived な externalConfig snapshot。 + // 再起動するまで mcpServers の入替えは反映しない (実 SDK は setMcpServers で動的更新できるが + // MVP では undo/redo を許さず、変更は次回 ChatRunner 起動から有効にする)。 + private cachedExternalConfigById: Map | null = null; + constructor(deps: ChatRunnerDeps) { this.deps = deps; } @@ -84,7 +163,7 @@ export class ChatRunner { // ProjectStore から該当ノードを引いて prompt の ブロックに埋め込む。 // 不在 ID は無視 (削除済みノード等)。永続化はせず、毎ターンの prompt prefix としてのみ使う。 async *runUserTurn(userText: string, contextNodeIds: string[] = []): AsyncGenerator { - const { sdk, chatStore, projectStore, projectDir, threadId } = this.deps; + const { chatStore, projectStore, threadId } = this.deps; const thread = await chatStore.getChat(threadId); if (!thread) { @@ -112,7 +191,6 @@ export class ChatRunner { const threadWithUser = await chatStore.getChat(threadId); const contextNodes = await loadContextNodes(projectStore, contextNodeIds); const prompt = buildChatPrompt(threadWithUser?.messages ?? [], contextNodes); - const systemPrompt = buildChatSystemPrompt(); // 3. 空の assistant message を append (後続の tool_use 即時永続化の親として必要) // prompt スナップショット後に行うことで、上記 buildChatPrompt の前提が崩れないようにする。 @@ -125,82 +203,375 @@ export class ChatRunner { }); yield { type: 'chat_assistant_message_started', messageId: assistantMsgId }; - // 4. MCP 経由で呼ばれる tool ハンドラ内で invokeInterceptedTool を回す。 - // MCP handler は SDK query を block するので、イベント emit は AsyncQueue 経由に分離する。 - // さもないと deadlock (SDK が MCP 応答待ち / MCP が承認待ち / 承認は UI 経由で queue flush が必要)。 + // 4. turn state を組み立てる。出力ループ (runOutputLoop) と MCP ハンドラはここから + // assistantMsgId / queue を読む。turn 中は this.currentTurn = 同じインスタンス。 + // ensureQuery より前に必ず set しておく — output loop が SDK メッセージを + // dispatch する瞬間に currentTurn が null だと取りこぼす (race)。 const queue = new EventQueue(); + const turnState: TurnState = { + assistantMsgId, + queue, + textBuffer: [], + stashedAuthUses: new Map(), + externalConfigById: this.cachedExternalConfigById ?? new Map(), + }; + this.currentTurn = turnState; + + // 5. SDK Query (long-lived) を必要なら起動する。OAuth state を turn 跨ぎで保持するため + // 1 ChatRunner = 1 sdk.query()。失敗 (mcpServers 設定不正等) は error を yield して終わる。 + try { + await this.ensureQuery(); + } catch (err) { + this.currentTurn = null; + yield { + type: 'error', + code: 'mcp_config_invalid', + message: err instanceof Error ? err.message : String(err), + }; + return; + } + // ensureQuery が externalConfig を更新するので turnState の参照を最新へ差し替える。 + turnState.externalConfigById = this.cachedExternalConfigById ?? new Map(); + + // 6. user message を SDK の input ストリームに push する。実 SDK は streaming input mode で + // 待機しており、ここで turn が始まる。出力ループが SDK 応答を queue に流し込む。 + if (this.input) { + this.input.push({ + type: 'user', + message: { role: 'user', content: prompt }, + parent_tool_use_id: null, + }); + } + + // 7. queue をドレイン。chat_turn_ended が来たら今 turn は終わり。 + // MCP ハンドラから push される pending/result も同じ queue を通る。 + try { + while (true) { + const evt = await queue.next(); + if (evt === null) break; + yield evt; + if (evt.type === 'chat_turn_ended') break; + } + } finally { + this.currentTurn = null; + // queue は finish しない: 出力ループから pending な MCP イベントが遅延で push される + // 可能性は無いが、念のため明示的に終わらせる必要は無い (turn が抜けた後は誰も読まない)。 + } + } + + // SDK query を 1 度だけ立ち上げ、出力ループをバックグラウンドで走らせる。 + // close() / iter 終端 / 例外で query が死んでいる場合は次回呼び出しで再起動する。 + // throw する条件: project mcpServers の設定不正 (URL 無効等) — 上位で error event 化される。 + private async ensureQuery(): Promise { + if (this.query && !this.outputLoopFailed) { + console.log('[chat-runner] ensureQuery: reuse existing query'); + return; + } + console.log( + '[chat-runner] ensureQuery: creating new query (failed?', + this.outputLoopFailed, + ')', + ); + // 既存が死んでいるなら片付けてから作り直す。 + this.tearDownQuery(); + + const { sdk, projectStore, projectDir } = this.deps; + const projectMeta = await projectStore.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + + const externalConfigById = new Map(); + for (const c of externalConfigs) externalConfigById.set(c.id, c.name); + this.cachedExternalConfigById = externalConfigById; + const tools = this.buildToolRegistry(); - const emit = (e: ChatEvent) => queue.push(e); - const mcp = this.buildMcpServer(tools, emit, assistantMsgId); + const mcp = this.buildMcpServer(tools); + const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); - const textBuffer: string[] = []; + const input = new AsyncIterableInput(); + this.input = input; - // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 - // generator 側は queue をドレインして yield するだけ。 - const sdkDone = (async () => { - try { - const iter = sdk.query({ - prompt, - options: { - systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, - tools: [], - allowedTools: [ - 'mcp__tally__create_node', - 'mcp__tally__create_edge', - 'mcp__tally__find_related', - 'mcp__tally__list_by_type', - ], - permissionMode: 'dontAsk', - settingSources: [], - cwd: projectDir, - ...(process.env.CLAUDE_CODE_PATH - ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_PATH } - : {}), - }, - }); + const query = sdk.query({ + prompt: input.iterable(), + options: { + systemPrompt: buildChatSystemPrompt(), + mcpServers: built.mcpServers, + tools: [], + allowedTools: built.allowedTools, + permissionMode: 'dontAsk', + settingSources: [], + cwd: projectDir, + ...(process.env.CLAUDE_CODE_PATH + ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_PATH } + : {}), + }, + }); + this.query = query; + this.outputLoopFailed = false; + this.outputLoopDone = this.runOutputLoop(query); + } - for await (const msg of iter) { - console.log('[chat-runner] sdk msg:', JSON.stringify(msg).slice(0, 200)); - const blocks = extractAssistantBlocks(msg); - for (const b of blocks) { - if (b.type === 'text') { - textBuffer.push(b.text); - queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); - } - } + // SDK query から流れてくる SDKMessage を進行中 turn の queue に振り分ける。 + // turn 終端は SDKResultMessage (type: 'result') の到達で判定し、chat_turn_ended を emit する。 + // iter が終わった (= subprocess 死亡 / close()) ときは進行中 turn にもエラーを通知して終わらせる。 + private async runOutputLoop(query: SdkQueryHandle): Promise { + console.log('[chat-runner] runOutputLoop: started'); + try { + for await (const msg of query) { + console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); + await this.dispatchSdkMessage(msg); + } + } catch (err) { + console.log('[chat-runner] runOutputLoop: error', err); + this.outputLoopFailed = true; + const turn = this.currentTurn; + if (turn) { + turn.queue.push({ type: 'error', code: 'agent_failed', message: String(err) }); + turn.queue.push({ type: 'chat_turn_ended' }); + turn.queue.finish(); + } + return; + } + console.log( + '[chat-runner] runOutputLoop: iter ended (currentTurn?', + this.currentTurn !== null, + ')', + ); + // iter 正常終端 (close 等)。進行中 turn が残っていれば打ち切る。 + // 同時に query が死んだ印として outputLoopFailed を立て、次回 ensureQuery で作り直させる。 + this.outputLoopFailed = true; + const turn = this.currentTurn; + if (turn) { + turn.queue.push({ type: 'chat_turn_ended' }); + turn.queue.finish(); + } + } + + // 1 つの SDKMessage を処理する。turn が無ければ捨てる。 + private async dispatchSdkMessage(msg: SdkMessageLike): Promise { + const turn = this.currentTurn; + if (!turn) return; + const { chatStore, threadId } = this.deps; + const { assistantMsgId, queue, textBuffer, stashedAuthUses, externalConfigById } = turn; + + // result message: turn 終了 + const m = msg as unknown as { type?: string; subtype?: string }; + if (m.type === 'result') { + // text blocks を assistant message の先頭に統合 (tool_use/result は intercept 経路で既に append 済み) + if (textBuffer.length > 0) { + const current = await chatStore.getChat(threadId); + const target = current?.messages.find((m2) => m2.id === assistantMsgId); + if (current && target) { + const textBlocks: ChatBlock[] = textBuffer.map((t) => ({ type: 'text', text: t })); + await chatStore.replaceMessageBlocks(threadId, assistantMsgId, [ + ...textBlocks, + ...target.blocks, + ]); } + } + queue.push({ type: 'chat_assistant_message_completed', messageId: assistantMsgId }); + queue.push({ type: 'chat_turn_ended' }); + return; + } - // text blocks を assistant message の先頭に統合 (tool_use/result は intercept 経路で既に append 済み) - if (textBuffer.length > 0) { - const current = await chatStore.getChat(threadId); - const target = current?.messages.find((m) => m.id === assistantMsgId); - if (current && target) { - const textBlocks: ChatBlock[] = textBuffer.map((t) => ({ type: 'text', text: t })); - await chatStore.replaceMessageBlocks(threadId, assistantMsgId, [ - ...textBlocks, - ...target.blocks, - ]); - } + const blocks = extractAssistantBlocks(msg); + for (const b of blocks) { + if (b.type === 'text') { + textBuffer.push(b.text); + queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); + } else if (b.type === 'tool_use') { + const authMatch = parseAuthToolName(b.name); + if (authMatch) { + const label = externalConfigById.get(authMatch.mcpServerId) ?? authMatch.mcpServerId; + stashedAuthUses.set(b.toolUseId, { match: authMatch, mcpServerLabel: label }); + continue; } + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_use', + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + source: 'external', + }); + queue.push({ + type: 'chat_tool_external_use', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result') { + const stash = stashedAuthUses.get(b.toolUseId); + if (stash) { + stashedAuthUses.delete(b.toolUseId); + await this.handleAuthToolResult({ + match: stash.match, + mcpServerLabel: stash.mcpServerLabel, + result: { ok: b.ok, output: b.output }, + assistantMsgId, + emit: (e) => queue.push(e), + }); + continue; + } + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: truncateForPersistence(b.output), + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + } + } + } + + private tearDownQuery(): void { + try { + this.input?.close(); + } catch { + /* swallow: close は idempotent */ + } + try { + this.query?.close?.(); + } catch { + /* swallow */ + } + this.input = null; + this.query = null; + this.outputLoopDone = null; + } - queue.push({ type: 'chat_assistant_message_completed', messageId: assistantMsgId }); - queue.push({ type: 'chat_turn_ended' }); - } catch (err) { - queue.push({ type: 'error', code: 'agent_failed', message: String(err) }); - } finally { - queue.finish(); + // ChatRunner 終了時 (WS close 等) に SDK subprocess を片付ける。 + // 進行中の turn があれば打ち切られ、queue.finish() を経て runUserTurn 側の for-await が + // 自然に抜ける。再度 runUserTurn を呼べば ensureQuery が再起動する (= 再 auth が必要)。 + async close(): Promise { + this.tearDownQuery(); + if (this.outputLoopDone) { + // 念のため出力ループの終了を待つ (リソースリーク防止)。 + try { + await this.outputLoopDone; + } catch { + /* swallow */ } - })(); + } + } + + // OAuth 認証系 tool_use/tool_result ペアを auth_request ブロックに変換する。 + // - authenticate: tool_result.output から auth URL を抽出して新規 pending ブロックを append + // - complete_authentication: 同 mcpServerId の最新 pending ブロックを completed/failed に更新 + // どちらの場合も chat_auth_request イベントを emit する (UI が card を再描画するための合図)。 + // tool_result の ok=false や URL 抽出失敗時は failed として扱い、UI に message を出す。 + private async handleAuthToolResult(opts: { + match: AuthToolNameMatch; + mcpServerLabel: string; + result: { ok: boolean; output: string }; + assistantMsgId: string; + emit: (e: ChatEvent) => void; + }): Promise { + const { match, mcpServerLabel, result, assistantMsgId, emit } = opts; + const { chatStore, threadId } = this.deps; - // 6. queue をドレイン。MCP handler から push される pending/result も含め全て通過する。 - while (true) { - const evt = await queue.next(); - if (evt === null) break; - yield evt; + if (match.kind === 'authenticate') { + const authUrl = result.ok ? extractAuthUrl(result.output) : null; + if (!authUrl) { + // URL 抽出に失敗 (フォーマット変更 / 認証 server エラー等)。failed として可視化。 + const failureMessage = result.ok + ? 'authenticate tool_result から URL を抽出できませんでした' + : result.output.slice(0, 256); + const placeholderUrl = 'https://invalid.invalid/?auth_url_unavailable'; + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }); + return; + } + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl, + status: 'pending', + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl, + status: 'pending', + }); + return; } - await sdkDone; // バックグラウンドタスクの未捕捉エラーを顕在化 + // complete_authentication: 最新 pending ブロックを更新する。 + const thread = await chatStore.getChat(threadId); + if (!thread) return; + const found = findLatestPendingAuthRequest(thread.messages, match.mcpServerId); + if (!found) { + // 対応する pending が無い (履歴外で auth 済 / 別 thread で auth 済 / 重複呼び出し)。 + // 失敗時は新規 failed ブロックで残す。成功時はサイレント (ノイズ防止)。 + if (!result.ok) { + const failureMessage = result.output.slice(0, 256); + const placeholderUrl = 'https://invalid.invalid/?orphan_complete_failed'; + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }); + } + return; + } + const updated: ChatBlock = result.ok + ? { ...found.block, status: 'completed' } + : { + ...found.block, + status: 'failed', + failureMessage: result.output.slice(0, 256), + }; + await chatStore.updateMessageBlock(threadId, found.messageId, found.blockIndex, updated); + emit({ + type: 'chat_auth_request', + messageId: found.messageId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: found.block.authUrl, + status: updated.status, + ...(updated.status === 'failed' && updated.failureMessage + ? { failureMessage: updated.failureMessage } + : {}), + }); } // 承認 intercept + 実ツール呼び出し。 @@ -234,6 +605,7 @@ export class ChatRunner { toolUseId: uiId, name: entry.name, input, + source: 'internal', approval: 'approved', }); await chatStore.appendBlockToMessage(threadId, assistantMsgId, { @@ -262,6 +634,7 @@ export class ChatRunner { toolUseId: uiToolUseId, name: entry.name, input, + source: 'internal', approval: 'pending', }); emit({ @@ -364,16 +737,32 @@ export class ChatRunner { // SDK 視点では通常の tool_use → tool_result の往復。 // 間に挟まる pending / result の ChatEvent は emit callback で直接 queue に流す // (sideEvents buffer にすると SDK block 中に flush できず deadlock するため)。 - private buildMcpServer(tools: ToolEntry[], emit: (e: ChatEvent) => void, assistantMsgId: string) { + private buildMcpServer(tools: ToolEntry[]) { const find = (name: string): ToolEntry => { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool not registered: ${name}`); return t; }; + // long-lived MCP server: handler 呼び出し時の「現 turn」から assistantMsgId / emit を読む。 + // SDK は tool 呼び出し中ずっと query を block するので、その間 currentTurn は不変が保証される。 + // turn が無い (= 想定外) ときは tool 呼び出しを失敗で返して保身する。 const makeHandler = (name: string) => async (input: unknown) => { const entry = find(name); - const { done } = this.invokeInterceptedTool({ entry, input, emit, assistantMsgId }); + const turn = this.currentTurn; + if (!turn) { + return { + content: [{ type: 'text' as const, text: 'no active turn (chat runner 状態異常)' }], + isError: true, + }; + } + const emit = (e: ChatEvent) => turn.queue.push(e); + const { done } = this.invokeInterceptedTool({ + entry, + input, + emit, + assistantMsgId: turn.assistantMsgId, + }); const result = await done; return { content: [{ type: 'text' as const, text: result.output }], @@ -542,12 +931,18 @@ export function formatNodeForContext(node: Node): string { } // チャット履歴を単一 prompt にエンコードする。 -// tool_use / tool_result は冗長なので省き、text block だけを role 付きで並べる。 -// 最後の user message は current として別タグに出し、モデルの「今答えるべきもの」を明示する。 +// 各 block を順に replay する: +// - text: そのまま (assistant / user の自然言語) +// - tool_use: ... +// - tool_result: ... +// +// T4 fix (Task 14): 旧版は text block だけ replay していたが、これだと AI が +// 2 ターン目以降で前ターンの外部 MCP tool_result (= Jira 等の読み取り内容) を忘れてしまい、 +// multi-turn 対話が成立しなかった。tool_use / tool_result も replay することで +// 「@JIRA EPIC-1 を読んで → 続けて子チケット STORY-2 も見て」が動く。 // // contextNodes: 今ターンで参照するコンテキストノード (issue #11)。 // 履歴より下、current_user_message より上に として埋め込む。 -// 履歴に積まないのは「ターンごとに添付し直しできる軽量な参照」という UX 設計のため。 export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = []): string { const lines: string[] = []; const last = messages[messages.length - 1]; @@ -556,14 +951,24 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = if (past.length > 0) { lines.push(''); for (const m of past) { - const texts = m.blocks - .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text); - if (texts.length > 0) { - lines.push(``); - lines.push(texts.join('\n')); - lines.push(''); + // block が 1 つも無い空 message は省く (空 assistant の preliminary append 等) + if (m.blocks.length === 0) continue; + lines.push(``); + for (const b of m.blocks) { + if (b.type === 'text') { + lines.push(b.text); + } else if (b.type === 'tool_use') { + // source は default 'internal'。external も含めて全部 replay する + // (AI に「外部 source を読んだ」事実を context として伝えるため) + const sourceAttr = b.source === 'external' ? ' source="external"' : ''; + lines.push( + `${JSON.stringify(b.input)}`, + ); + } else if (b.type === 'tool_result') { + lines.push(`${b.output}`); + } } + lines.push(''); } lines.push(''); } @@ -590,20 +995,62 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = return lines.join('\n'); } -// SDK から流れてくる assistant message の content 配列から text を取り出す。 -// tool_use は MCP 経路が処理するのでここでは無視する (重複処理を避ける)。 +// SDK から流れてくる assistant message + user message (tool_result を含む) から block 抽出。 +// 拾うもの: +// - assistant.text (existing 動作維持) +// - tool_use で name が mcp__tally__ で始まらないもの (= 外部 MCP、Task 12) +// - tool_result 全部 (外部 MCP の応答、user message に含まれる) +// +// Tally MCP (mcp__tally__*) の tool_use は createSdkMcpServer の intercept 経路で +// invokeInterceptedTool が処理するので、ここで拾うと二重処理になる。よって除外。 // 実行時 duck typing (agent-runner.ts の sdkMessageToAgentEvent と同じパターン)。 function extractAssistantBlocks(msg: SdkMessageLike): ExtractedBlock[] { const m = msg as unknown as { type?: string; message?: { content?: unknown[] } }; - if (m.type !== 'assistant' || !m.message?.content) return []; + if ((m.type !== 'assistant' && m.type !== 'user') || !m.message?.content) return []; const out: ExtractedBlock[] = []; for (const block of m.message.content) { const b = block as { type?: string; text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; }; - if (b.type === 'text' && typeof b.text === 'string') { + if (b.type === 'text' && typeof b.text === 'string' && m.type === 'assistant') { out.push({ type: 'text', text: b.text }); + } else if ( + b.type === 'tool_use' && + typeof b.id === 'string' && + typeof b.name === 'string' && + !b.name.startsWith('mcp__tally__') + ) { + out.push({ + type: 'tool_use', + toolUseId: b.id, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') { + // content は string or [{type:'text', text:'...'}] で来る (SDK 仕様)。string 化する。 + let outputText = ''; + if (typeof b.content === 'string') { + outputText = b.content; + } else if (Array.isArray(b.content)) { + outputText = b.content + .map((c: { type?: string; text?: string }) => + c.type === 'text' && typeof c.text === 'string' ? c.text : '', + ) + .join(''); + } + out.push({ + type: 'tool_result', + toolUseId: b.tool_use_id, + ok: b.is_error !== true, + output: outputText, + }); } } return out; diff --git a/packages/ai-engine/src/config.test.ts b/packages/ai-engine/src/config.test.ts index 5b941cc..b682bab 100644 --- a/packages/ai-engine/src/config.test.ts +++ b/packages/ai-engine/src/config.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from './config'; describe('loadConfig', () => { - it('デフォルト PORT は 4000', () => { + it('デフォルト PORT は 3322 (3321 frontend の隣、他プロジェクトと衝突しがちな 3000/4000/4001/5050 を避ける)', () => { const cfg = loadConfig({}); - expect(cfg.port).toBe(4000); + expect(cfg.port).toBe(3322); }); it('AI_ENGINE_PORT を解釈する', () => { diff --git a/packages/ai-engine/src/config.ts b/packages/ai-engine/src/config.ts index 43ce3dc..cd26d9f 100644 --- a/packages/ai-engine/src/config.ts +++ b/packages/ai-engine/src/config.ts @@ -6,7 +6,9 @@ export interface AiEngineConfig { // 認証情報は扱わない (Claude Code OAuth トークンを SDK が暗黙で拾う)。 export function loadConfig(env: NodeJS.ProcessEnv): AiEngineConfig { const raw = env.AI_ENGINE_PORT; - if (raw === undefined || raw === '') return { port: 4000 }; + // default 3322: 3321 (frontend) の隣。3000/4000/4001/5050 等は他プロジェクトと衝突しがち。 + // env AI_ENGINE_PORT で上書き可能。 + if (raw === undefined || raw === '') return { port: 3322 }; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n <= 0) { throw new Error(`AI_ENGINE_PORT が不正: ${raw}`); diff --git a/packages/ai-engine/src/duplicate-guards/coderef.test.ts b/packages/ai-engine/src/duplicate-guards/coderef.test.ts new file mode 100644 index 0000000..a23ccc1 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.test.ts @@ -0,0 +1,147 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { coderefGuard } from './coderef'; +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + // 注: exactOptionalPropertyTypes のため codebaseId は明示 undefined にせず、 + // override で指定された場合のみ広げる。 + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('coderefGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "coderef"', () => { + expect(coderefGuard.adoptAs).toBe('coderef'); + }); + + it('同一 filePath + 近接 startLine (±10) で重複検知', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 105, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + expect(res?.reason).toContain('n1'); + }); + + it('11 行以上離れていれば重複ではない', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 112, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('codebaseId が異なれば別物扱い (重複ではない)', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb2' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('input の codebaseId が無くても ctx.codebaseId が使われる', async () => { + const ctx = makeCtx( + [{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }], + { codebaseId: 'cb1' }, + ); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('既存 codebaseId が undefined でも横断的に重複扱い (legacy migration 対応)', async () => { + const ctx = makeCtx([ + { id: 'n_legacy', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('filePath が "./" 付きでも正規化して判定', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: './src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('proposal (adoptAs="coderef") も重複検知の対象', async () => { + const ctx = makeCtx([ + { + id: 'p1', + type: 'proposal', + adoptAs: 'coderef', + filePath: 'src/a.ts', + startLine: 100, + }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('filePath / startLine が input に無ければ skip (null)', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check({ title: 'T', body: '', additional: undefined }, ctx); + expect(res).toBeNull(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/coderef.ts b/packages/ai-engine/src/duplicate-guards/coderef.ts new file mode 100644 index 0000000..edd3f6d --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の findDuplicateCoderef ロジックを移行 (動作不変)。 +// `find-related-code` / `analyze-impact` はスキャン位置がブレやすいので、 +// 同一 filePath で ±10 行以内の近接 coderef を重複扱いする。 +const CODEREF_LINE_TOLERANCE = 10; + +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 +function normalizeFilePath(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + +export const coderefGuard: DuplicateGuard = { + adoptAs: 'coderef', + async check(input, ctx) { + const additional = input.additional ?? {}; + const fp = additional.filePath; + const sl = additional.startLine; + if (typeof fp !== 'string' || typeof sl !== 'number') return null; + + const normalized = normalizeFilePath(fp); + // input 側の codebaseId 優先、無ければ ctx の codebaseId を使う + const inputCb = + typeof additional.codebaseId === 'string' ? (additional.codebaseId as string) : undefined; + const activeCbId = inputCb ?? ctx.codebaseId; + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + // 正規 coderef と adoptAs=coderef proposal の両方を対象 + const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); + if (!isCoderef) continue; + const existingFp = rec.filePath as string | undefined; + const existingSl = rec.startLine as number | undefined; + if (!existingFp || typeof existingSl !== 'number') continue; + if (normalizeFilePath(existingFp) !== normalized) continue; + // マルチコードベース: 両方が codebaseId を持ち、かつ異なれば別物扱い。 + // 一方でも undefined なら従来通り全件比較 (legacy migration 対応)。 + const existingCb = rec.codebaseId as string | undefined; + if (activeCbId !== undefined && existingCb !== undefined && existingCb !== activeCbId) { + continue; + } + if (Math.abs(existingSl - sl) <= CODEREF_LINE_TOLERANCE) { + const id = rec.id as string; + return { + reason: `重複: ${id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(existingSl - sl)})`, + }; + } + } + return null; + }, +}; diff --git a/packages/ai-engine/src/duplicate-guards/index.test.ts b/packages/ai-engine/src/duplicate-guards/index.test.ts new file mode 100644 index 0000000..69c51f5 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.test.ts @@ -0,0 +1,122 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + __resetGuardsForTest, + type DuplicateGuard, + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, + registerGuard, +} from './index'; + +const fakeStore = { + listNodes: async () => [], + findRelatedNodes: async () => [], +} as unknown as ProjectStore; + +const baseCtx: DuplicateGuardContext = { + store: fakeStore, + anchorId: '', + sessionMemo: new Set(), +}; + +beforeEach(() => { + __resetGuardsForTest(); +}); + +describe('dispatchDuplicateGuard', () => { + it('登録された guard が無い adoptAs は null を返す', async () => { + const result = await dispatchDuplicateGuard( + 'requirement', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); + + it('guard が DuplicateFound を返したら dispatcher も同じものを返す', async () => { + const stubGuard: DuplicateGuard = { + adoptAs: 'usecase', + check: async () => ({ reason: '重複: stub' }), + }; + registerGuard(stubGuard); + const result = await dispatchDuplicateGuard( + 'usecase', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('重複: stub'); + }); + + it('複数 guard が同じ adoptAs に登録された場合、最初に重複を検知したものが返る', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => ({ reason: 'B が検知' }), + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'userstory', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('B が検知'); + }); + + it('全 guard が null なら null を返す', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'issue', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); +}); + +describe('notifyCreated', () => { + it('登録された guard の onCreated が呼ばれる', async () => { + const calls: string[] = []; + const guard: DuplicateGuard = { + adoptAs: 'coderef', + check: async () => null, + onCreated: (input) => { + calls.push(input.title); + }, + }; + registerGuard(guard); + notifyCreated('coderef', { title: 'T1', body: '', additional: undefined }, baseCtx); + expect(calls).toEqual(['T1']); + }); + + it('onCreated が無い guard では何も起きない (例外も出ない)', async () => { + const guard: DuplicateGuard = { + adoptAs: 'question', + check: async () => null, + }; + registerGuard(guard); + expect(() => + notifyCreated('question', { title: 'T2', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); + + it('登録 guard が無い adoptAs では何も起きない', () => { + expect(() => + notifyCreated('coderef', { title: 'T3', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts new file mode 100644 index 0000000..90ee894 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -0,0 +1,83 @@ +import type { AdoptableType } from '@tally/core'; +import type { ProjectStore } from '@tally/storage'; + +// create-node 入力のうち guard に必要な最小 shape。 +export interface GuardInput { + title: string; + body: string; + additional: Record | undefined; +} + +// guard が共有するランタイム文脈。 +export interface DuplicateGuardContext { + store: ProjectStore; + // anchor 無し (chat) のときは空文字。anchor 依存 guard は空文字を skip せよ。 + anchorId: string; + // セッション内で生成済みノードの重複記録。キーは guard 実装が決める。 + sessionMemo: Set; + // マルチコードベース対応のために流すコードベース ID (optional)。 + codebaseId?: string; +} + +export interface DuplicateFound { + reason: string; // ユーザー向けメッセージ (既存 node id などを含む) +} + +export interface DuplicateGuard { + // 対象 adoptAs。複数対応は同 guard を複数 adoptAs で登録する。 + adoptAs: AdoptableType; + // 重複があれば DuplicateFound、無ければ null。 + check(input: GuardInput, ctx: DuplicateGuardContext): Promise; + // 生成成功後に呼ばれる (sessionMemo 更新など)。任意。 + onCreated?(input: GuardInput, ctx: DuplicateGuardContext): void; +} + +// adoptAs → Guard[] のレジストリ。Task 7-9 で個別 guard を追加する。 +const REGISTRY = new Map(); + +export function registerGuard(guard: DuplicateGuard): void { + const list = REGISTRY.get(guard.adoptAs) ?? []; + list.push(guard); + REGISTRY.set(guard.adoptAs, list); +} + +// dispatcher: 登録 guard を順に check し、最初に重複を見つけたら返す。 +// 全部 null なら null。Promise を一つずつ await する (並列にしない: 副作用順序を保つ)。 +export async function dispatchDuplicateGuard( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): Promise { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) { + const found = await g.check(input, ctx); + if (found) return found; + } + return null; +} + +// 生成成功通知: 登録 guard の onCreated を全部呼ぶ。 +export function notifyCreated( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): void { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) g.onCreated?.(input, ctx); +} + +// テスト用: REGISTRY をクリア。プロダクションコードからは呼ばないこと。 +// 命名 prefix で「test-only」を明示し、accidental 使用を防ぐ。 +export function __resetGuardsForTest(): void { + REGISTRY.clear(); +} + +import { coderefGuard } from './coderef'; +import { questionGuard } from './question'; +import { sourceUrlGuard } from './source-url'; + +// 個別 guard を register する (module load 時の副作用)。 +// テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 +registerGuard(coderefGuard); +registerGuard(questionGuard); +registerGuard(sourceUrlGuard); diff --git a/packages/ai-engine/src/duplicate-guards/question.test.ts b/packages/ai-engine/src/duplicate-guards/question.test.ts new file mode 100644 index 0000000..6b43554 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.test.ts @@ -0,0 +1,110 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import { questionGuard } from './question'; + +function makeCtx( + neighbors: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + const ctx: DuplicateGuardContext = { + store: { + listNodes: async () => [], + findRelatedNodes: async () => neighbors as never, + } as unknown as ProjectStore, + anchorId: 'anchor-1', + sessionMemo: new Set(), + ...override, + }; + return ctx; +} + +describe('questionGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "question"', () => { + expect(questionGuard.adoptAs).toBe('question'); + }); + + it('anchorId が空なら skip (null を返す) — T1 fix の前提', async () => { + const ctx = makeCtx([], { anchorId: '' }); + const res = await questionGuard.check( + { title: '[AI] Q', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('同 anchor に同タイトル正規 question が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('同 anchor に同タイトル proposal (adoptAs=question) が既にあれば重複', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'question', title: '[AI] どうするか' }, + ]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('[AI] prefix の有無を吸収して比較する', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: '[AI] どうするか' }]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + }); + + it('sessionMemo に同 anchor+title が記録済みなら重複 (同セッション内の連続生成防止)', async () => { + const ctx = makeCtx([], { sessionMemo: new Set(['anchor-1|どうするか']) }); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('別タイトルなら重複ではない', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] 別の論点', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('近傍に他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('onCreated が anchorId+title を sessionMemo に追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] 新しい論点', body: '', additional: undefined }, ctx); + expect(memo.has('anchor-1|新しい論点')).toBe(true); + }); + + it('onCreated は anchorId が空なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { anchorId: '', sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] X', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/question.ts b/packages/ai-engine/src/duplicate-guards/question.ts new file mode 100644 index 0000000..20fc189 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.ts @@ -0,0 +1,48 @@ +import { stripAiPrefix } from '@tally/core'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の question 用重複ガード (sessionQuestionKeys + findRelatedNodes) を移行。 +// +// T1 fix の前提: chat 経由 (anchorId が空) ではこの guard は skip し、 +// Task 9 の sourceUrl guard が代替で重複検知する。 +// +// 比較方針: +// - title は stripAiPrefix で "[AI]" prefix を剥がしてから比較 (AI 提案と人間生成の混在対応) +// - 同セッション内の連続生成は sessionMemo (anchorId|normalizedTitle) で短絡防止 +// - DB 側は anchor の近傍 (findRelatedNodes) を引き、同タイトルの正規 question or +// proposal(adoptAs=question) があれば重複扱い +export const questionGuard: DuplicateGuard = { + adoptAs: 'question', + async check(input, ctx) { + // T1: anchorId が空なら skip (chat 経路で findRelatedNodes('') は空配列) + if (!ctx.anchorId) return null; + + const normalizedTitle = stripAiPrefix(input.title); + const sessionKey = `${ctx.anchorId}|${normalizedTitle}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): anchor ${ctx.anchorId} に既に同タイトル question を生成済み`, + }; + } + + const neighbors = await ctx.store.findRelatedNodes(ctx.anchorId); + for (const n of neighbors) { + const rec = n as unknown as { id: string; type: string; adoptAs?: string; title: string }; + const isQuestion = + rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); + if (isQuestion && stripAiPrefix(rec.title) === normalizedTitle) { + return { + reason: `重複: anchor ${ctx.anchorId} に既に同タイトル question 候補 ${rec.id} が存在`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + // anchor 無し (chat) では memo しない (T1 fix の対称) + if (!ctx.anchorId) return; + const normalizedTitle = stripAiPrefix(input.title); + ctx.sessionMemo.add(`${ctx.anchorId}|${normalizedTitle}`); + }, +}; diff --git a/packages/ai-engine/src/duplicate-guards/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts new file mode 100644 index 0000000..d1bd977 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -0,0 +1,170 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import { sourceUrlGuard } from './source-url'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('sourceUrlGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "requirement"', () => { + expect(sourceUrlGuard.adoptAs).toBe('requirement'); + }); + + it('sourceUrl が additional に無ければ skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: undefined }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('sourceUrl が空文字なら skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: '' } }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('同 sourceUrl の正規 requirement が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + expect(res?.reason).toContain('https://jira.test/EPIC-1'); + }); + + it('同 sourceUrl の proposal(adoptAs=requirement) も重複検知対象', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }, + ]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('別 sourceUrl なら重複ではない', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-2' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('sessionMemo に記録済みなら重複 (連続生成防止)', async () => { + const memo = new Set(['sourceUrl:https://jira.test/EPIC-1']); + const ctx = makeCtx([], { sessionMemo: memo }); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知 — T1 fix の核', async () => { + const ctx = makeCtx( + [{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }], + { anchorId: '' }, + ); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + }); + + it('onCreated で sessionMemo にキーを追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(memo.has('sourceUrl:https://jira.test/EPIC-1')).toBe(true); + }); + + it('onCreated は sourceUrl が無いときは何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); + + it('onCreated は sourceUrl が空文字なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: { sourceUrl: '' } }, ctx); + expect(memo.size).toBe(0); + }); + + // CodeRabbit 指摘 (PR #18): sourceUrl を生値のまま比較していたため、 + // 前後空白付き入力 (" https://... ") が同一 URL の重複検知をすり抜けていた。 + // trim 正規化を入れて、入力側も既存ノード側も揃えてから比較する。 + it('sourceUrl の前後空白は正規化して既存ノードと比較する (重複検知)', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: ' https://jira.test/EPIC-1 ' } }, + ctx, + ); + expect(res).not.toBeNull(); + }); + + it('既存ノード側の sourceUrl に前後空白があっても正規化して比較する', async () => { + const ctx = makeCtx([ + { id: 'r1', type: 'requirement', sourceUrl: ' https://jira.test/EPIC-1 ' }, + ]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res).not.toBeNull(); + }); + + it('sourceUrl が空白のみなら skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: ' ' } }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('onCreated でも trim した値が memo に入る', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: ' https://jira.test/EPIC-X ' } }, + ctx, + ); + expect(memo.has('sourceUrl:https://jira.test/EPIC-X')).toBe(true); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/source-url.ts b/packages/ai-engine/src/duplicate-guards/source-url.ts new file mode 100644 index 0000000..39e5b84 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.ts @@ -0,0 +1,64 @@ +import type { DuplicateGuard } from './index'; + +// sourceUrl ベースの重複検知。 +// +// T1 fix の核: anchor 不要 → chat (anchorId='') でも動く。 +// 既存 question guard は anchorId に依存して findRelatedNodes 経由で動くが、 +// chat 経路では anchorId が空文字で findRelatedNodes('') が空配列を返すため重複ガードが dead。 +// sourceUrl は anchor 概念がないので、グラフ全件スキャンで重複検知する。 +// +// 対象: +// - 正規 requirement (`type === 'requirement'`) +// - adoptAs=requirement の proposal (`type === 'proposal' && adoptAs === 'requirement'`) +// +// memo キー: `sourceUrl:${url}` (anchor 非依存、グラフ横断で一意) +const SESSION_KEY_PREFIX = 'sourceUrl:'; + +// 入力 / 永続化済み URL を比較可能な形に揃える。 +// 前後空白がある入力 (" https://jira.../X ") を許容してしまうと、 +// memo キーと比較値がずれて同一 URL が二重登録される。 +// CodeRabbit 指摘 PR #18: trim 必須。空文字 / 非 string は null にする。 +function normalizeSourceUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export const sourceUrlGuard: DuplicateGuard = { + adoptAs: 'requirement', + async check(input, ctx) { + const sourceUrl = normalizeSourceUrl(input.additional?.sourceUrl); + if (!sourceUrl) return null; + + const sessionKey = `${SESSION_KEY_PREFIX}${sourceUrl}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): sourceUrl ${sourceUrl} を既に生成済み`, + }; + } + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + const isRequirement = + type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); + if (!isRequirement) continue; + const existingUrl = normalizeSourceUrl(rec.sourceUrl); + if (existingUrl === sourceUrl) { + const id = rec.id as string; + return { + reason: `重複: sourceUrl ${sourceUrl} は既に node ${id} が保持`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + const sourceUrl = normalizeSourceUrl(input.additional?.sourceUrl); + if (sourceUrl) { + ctx.sessionMemo.add(`${SESSION_KEY_PREFIX}${sourceUrl}`); + } + }, +}; diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts new file mode 100644 index 0000000..59b262d --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMcpServers } from './build-mcp-servers'; + +describe('buildMcpServers', () => { + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { + const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [] }); + expect(Object.keys(result.mcpServers)).toEqual(['tally']); + expect(result.allowedTools).toEqual(['mcp__tally__*']); + }); + + it('atlassian 1 個 → HTTP config (url のみ、Authorization header なし) + allowedTools', () => { + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian', + url: 'https://mcp.atlassian.example/v1/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers.atlassian as { + type: string; + url: string; + headers?: unknown; + }; + expect(atlassian.type).toBe('http'); + expect(atlassian.url).toBe('https://mcp.atlassian.example/v1/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian.headers).toBeUndefined(); + expect(result.allowedTools).toContain('mcp__tally__*'); + expect(result.allowedTools).toContain('mcp__atlassian__*'); + }); + + it('複数の config を合成 → 各々が独立に build される', () => { + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'first', + name: 'F', + kind: 'atlassian', + url: 'https://a.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + { + id: 'second', + name: 'S', + kind: 'atlassian', + url: 'https://b.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + expect(Object.keys(result.mcpServers)).toEqual(['tally', 'first', 'second']); + const first = result.mcpServers.first as { url: string; headers?: unknown }; + const second = result.mcpServers.second as { url: string; headers?: unknown }; + expect(first.url).toBe('https://a.test/mcp'); + expect(second.url).toBe('https://b.test/mcp'); + expect(first.headers).toBeUndefined(); + expect(second.headers).toBeUndefined(); + expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']); + }); +}); diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.ts b/packages/ai-engine/src/mcp/build-mcp-servers.ts new file mode 100644 index 0000000..035167c --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -0,0 +1,44 @@ +import type { McpServerConfig } from '@tally/core'; + +// SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 +// chat-runner / agent-runner が共通で使える shape にする。 +// +// 認証方針 (Premise 9 撤回後): +// MCP プロトコルの OAuth 2.1 を採用し、Tally は credentials を一切扱わない。 +// - 401 を受けたら Claude Agent SDK が WWW-Authenticate から OAuth metadata を取り、 +// ブラウザ経由 (or device flow) で auth、token 管理は SDK 側で完結する。 +// - ここでは Authorization header を組み立てない。url のみを SDK に渡す。 +// - PAT 認証の MCP server (sooperset 等) を使う場合は、その server 自身が起動時 env で +// credentials を持つ前提 (Tally は header passthrough しない)。 +// +// allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 +export interface BuildMcpServersInput { + // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 + tallyMcp: unknown; + // プロジェクト設定 project.mcpServers[]。 + configs: McpServerConfig[]; +} + +export interface BuildMcpServersResult { + mcpServers: Record; + allowedTools: string[]; +} + +// SDK 設定と allowedTools を組み立てる。 +// 認証は MCP 側 (SDK の OAuth 2.1 / MCP server 自身) に委譲しており、Tally は touch しない。 +export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { + const { tallyMcp, configs } = input; + + const mcpServers: Record = { tally: tallyMcp }; + const allowedTools: string[] = ['mcp__tally__*']; + + for (const cfg of configs) { + mcpServers[cfg.id] = { + type: 'http' as const, + url: cfg.url, + }; + allowedTools.push(`mcp__${cfg.id}__*`); + } + + return { mcpServers, allowedTools }; +} diff --git a/packages/ai-engine/src/mcp/redact.test.ts b/packages/ai-engine/src/mcp/redact.test.ts new file mode 100644 index 0000000..30877f8 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { redactMcpSecrets } from './redact'; + +describe('redactMcpSecrets', () => { + it('Authorization header を "***" に置換', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer abc-123' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + // 元オブジェクトは破壊しない (immutable) + expect(input.mcpServers.atlassian.headers.Authorization).toBe('Bearer abc-123'); + }); + + it('Basic auth (Basic xxx) も同じく redact', () => { + const input = { + mcpServers: { + cloud: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { cloud: { headers: Record } }; + }; + expect(out.mcpServers.cloud.headers.Authorization).toBe('***'); + }); + + it('他の header は保持', () => { + const out = redactMcpSecrets({ + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer secret', 'X-Other': 'keep' }, + }, + }, + }) as { + mcpServers: { atlassian: { url: string; headers: Record } }; + }; + expect(out.mcpServers.atlassian.url).toBe('https://x.test/mcp'); + expect(out.mcpServers.atlassian.headers['X-Other']).toBe('keep'); + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); + + it('mcpServers 不在ならそのまま返す', () => { + const input = { foo: 'bar' }; + expect(redactMcpSecrets(input)).toEqual(input); + }); + + it('mcpServers 内に headers が無いサーバ (SDK type 等) は触らない', () => { + const sdkServer = { type: 'sdk', name: 'tally' }; + const input = { + mcpServers: { tally: sdkServer }, + }; + const out = redactMcpSecrets(input) as { mcpServers: Record }; + expect(out.mcpServers.tally).toEqual(sdkServer); + }); + + it('複数サーバが混在しても各々を独立に処理', () => { + const input = { + mcpServers: { + tally: { type: 'sdk', name: 'tally' }, + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer xyz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { + tally: { type: string }; + atlassian: { type: string; headers?: Record }; + }; + }; + expect(out.mcpServers.tally.type).toBe('sdk'); + expect(out.mcpServers.atlassian.headers?.Authorization).toBe('***'); + }); + + it('non-object input (primitive / null / array) はそのまま返す', () => { + expect(redactMcpSecrets(null)).toBe(null); + expect(redactMcpSecrets(undefined)).toBe(undefined); + expect(redactMcpSecrets(42)).toBe(42); + expect(redactMcpSecrets('string')).toBe('string'); + expect(redactMcpSecrets([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('Authorization 値が配列等の非 string でも redact される (安全側に倒す)', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { + Authorization: ['Bearer xxx'] as unknown as string, + }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); +}); diff --git a/packages/ai-engine/src/mcp/redact.ts b/packages/ai-engine/src/mcp/redact.ts new file mode 100644 index 0000000..bcc1289 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.ts @@ -0,0 +1,46 @@ +// SDK に渡す mcpServers 設定 (Authorization header) をログに出す前の安全な形に変換する。 +// プロセスメモリには PAT が残るが、ログ出力経路では "***" にする。 +// 元オブジェクトは破壊せず、shallow copy で返す (mcpServers と該当 server / headers のみ複製)。 +// +// 注意: +// - Authorization header の検出は **canonical な "Authorization" のみ** (case-sensitive)。 +// Claude Agent SDK は McpHttpServerConfig.headers を canonical 表記で吐くため十分。 +// 将来 SDK 仕様変更で "authorization" 等の表記が混在する場合は本関数を更新する。 +// - 現状は Authorization のみ redact 対象。Cookie / X-API-Key / Proxy-Authorization 等は対応外。 +// MVP の MCP HTTP transport では Authorization 以外の credential header を使わないため。 +export function redactMcpSecrets(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + + const obj = value as Record; + if (!obj.mcpServers || typeof obj.mcpServers !== 'object' || Array.isArray(obj.mcpServers)) { + return value; + } + + const servers = obj.mcpServers as Record; + const redactedServers: Record = {}; + + for (const [name, cfg] of Object.entries(servers)) { + if (cfg && typeof cfg === 'object' && !Array.isArray(cfg) && 'headers' in cfg) { + const src = cfg as { headers?: unknown }; + const headers = src.headers; + if ( + headers && + typeof headers === 'object' && + !Array.isArray(headers) && + 'Authorization' in headers + ) { + redactedServers[name] = { + ...(cfg as Record), + headers: { + ...(headers as Record), + Authorization: '***', + }, + }; + continue; + } + } + redactedServers[name] = cfg; + } + + return { ...obj, mcpServers: redactedServers }; +} diff --git a/packages/ai-engine/src/server.test.ts b/packages/ai-engine/src/server.test.ts index 7d0d50e..e39bffe 100644 --- a/packages/ai-engine/src/server.test.ts +++ b/packages/ai-engine/src/server.test.ts @@ -24,6 +24,7 @@ describe('WS /agent', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -158,6 +159,7 @@ describe('WS /chat', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/server.ts b/packages/ai-engine/src/server.ts index cc9b1c8..60e0175 100644 --- a/packages/ai-engine/src/server.ts +++ b/packages/ai-engine/src/server.ts @@ -143,6 +143,7 @@ function handleAgentConnection(ws: WebSocket, sdk: SdkLike): void { // approve_tool は runner.approveTool へ同期的にデリゲート (pendingApprovals の Promise を resolve)。 // 切断で runner は破棄 (pending な承認は喪失するが、次回 open で永続化済み状態から再開できる)。 function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { + console.log('[server] /chat WS connected'); const send = (evt: ChatEvent) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt)); }; @@ -248,4 +249,19 @@ function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { message: `unknown message type: ${String(obj.type)}`, }); }); + + // WS が閉じたら ChatRunner も片付ける (long-lived SDK subprocess を終わらせる)。 + // close を呼ばないと subprocess + MCP HTTP transport がプロセス終了まで生き残る。 + ws.on('close', (code, reason) => { + console.log('[server] /chat WS closed:', code, reason?.toString()); + if (runner) { + const r = runner; + runner = null; + // close 内部の例外 (subprocess kill 失敗など) を unhandled rejection に + // しないために .catch で握る。観測できるよう warn は出す。 + r.close().catch((err) => { + console.warn('[server] runner.close() failed:', err); + }); + } + }); } diff --git a/packages/ai-engine/src/stream.ts b/packages/ai-engine/src/stream.ts index 040573c..15ab206 100644 --- a/packages/ai-engine/src/stream.ts +++ b/packages/ai-engine/src/stream.ts @@ -40,6 +40,35 @@ export type ChatEvent = } | { type: 'chat_assistant_message_completed'; messageId: string } | { type: 'chat_turn_ended' } + // 外部 MCP (mcp__tally__ 以外) の tool_use を承認なしで永続化するときに発火。 + // AI が外部ソースを read したことを UI に見える形で残す (Task 12)。 + | { + type: 'chat_tool_external_use'; + messageId: string; + toolUseId: string; + name: string; + input: unknown; + } + // 外部 MCP の tool_result。AI が読んだ外部ソースの内容を UI に展開可能で表示する (Task 12)。 + | { + type: 'chat_tool_external_result'; + messageId: string; + toolUseId: string; + ok: boolean; + output: string; + } + // 外部 MCP の OAuth 2.1 認証要求。SDK の authenticate tool_use を検出して + // tool_use/tool_result の代わりに UI に流す。pending → completed/failed の遷移は + // 同 thread 内の complete_authentication tool_use 検出時に追って emit する。 + | { + type: 'chat_auth_request'; + messageId: string; + mcpServerId: string; + mcpServerLabel: string; + authUrl: string; + status: 'pending' | 'completed' | 'failed'; + failureMessage?: string; + } | { type: 'error'; code: string; message: string }; // SDK の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 diff --git a/packages/ai-engine/src/tools/create-node.test.ts b/packages/ai-engine/src/tools/create-node.test.ts index a5e4b0a..bdcf089 100644 --- a/packages/ai-engine/src/tools/create-node.test.ts +++ b/packages/ai-engine/src/tools/create-node.test.ts @@ -695,3 +695,159 @@ describe('adoptAs=question の anchor+同タイトル重複ガード', () => { expect(r2.ok).toBe(true); }); }); + +describe('createNodeHandler — sourceUrl 重複ガード (Task 9 連動)', () => { + // duplicate-guards/source-url.ts と組み合わさった統合テスト。 + // Task 10 の主目的: dispatcher 経由で sourceUrl guard が発火することを担保する。 + + it('同 sourceUrl の requirement が既にあれば 2 度目の作成は fail (chat anchor 無し経路)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + // chat 経路: anchorId は空文字 + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-1' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知が動く (proposal 既存)', async () => { + const existing = [ + { + id: 'prop-req-0', + type: 'proposal', + adoptAs: 'requirement', + x: 0, + y: 0, + title: '[AI] Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-2', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue 再取込', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('別 sourceUrl なら重複扱いしない (新規 requirement として通る)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira PROJ-1', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira PROJ-2', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + expect(added[0]?.sourceUrl).toBe('https://example.atlassian.net/browse/PROJ-2'); + }); + + it('sourceUrl 無し (legacy / 非外部 ingest) は guard skip → 通常通り作成', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: '内部要求', + body: '', + // sourceUrl 無し + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: '別の内部要求', + body: '', + // sourceUrl 無し → guard skip + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + }); +}); diff --git a/packages/ai-engine/src/tools/create-node.ts b/packages/ai-engine/src/tools/create-node.ts index 6a9886a..75c27dd 100644 --- a/packages/ai-engine/src/tools/create-node.ts +++ b/packages/ai-engine/src/tools/create-node.ts @@ -1,16 +1,26 @@ import path from 'node:path'; import type { AdoptableType, AgentName, ProposalNode } from '@tally/core'; -import { newQuestionOptionId, stripAiPrefix } from '@tally/core'; +import { newQuestionOptionId } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { z } from 'zod'; +import { + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, +} from '../duplicate-guards/index'; import type { AgentEvent } from '../stream'; // create_node: ツールハンドラ。AI は proposal しか作れない (ADR-0005 前提)。 // adoptAs は「採用されたら何になるか」を宣言。title に [AI] プレフィックスが無ければ自動付与。 // x/y 未指定時は呼び出し元が与える anchor 座標を基準に自動オフセット配置。 -// coderef の場合は filePath を正規化し、近接する既存 coderef があれば重複としてガードする。 +// +// 重複検知は duplicate-guards/ の strategy map に委譲 (Task 6-9 で抽出)。 +// ここでは「保存内容の整合性」だけ責任を持つ: +// - coderef の filePath 正規化と codebaseId 注入 (DB に書く値そのものを揃える) +// - question の options 正規化と min 2 検証 (採用後 decision 不能を防ぐ) +// - dispatcher 呼び出し → addNode → notifyCreated const ADOPTABLE_TYPES = [ 'requirement', @@ -36,7 +46,7 @@ export interface CreateNodeDeps { store: ProjectStore; emit: (e: AgentEvent) => void; anchor: { x: number; y: number }; - // anchor ノードの id。question 重複ガードで近傍を引くために使う。 + // anchor ノードの id。question 重複ガードで近傍を引くために使う。chat 経路は空文字。 anchorId: string; // AI が生成した proposal に sourceAgentId として刻むエージェント名。 // どの agent が作ったかを後から辿れるようにするため required。 @@ -51,63 +61,26 @@ export interface ToolResult { output: string; } -// filePath 近接判定の許容行数。`find-related-code` / `analyze-impact` は -// スキャン位置がブレやすく、同一箇所を複数 proposal として追加しがちなので -// ±10 行以内を重複とみなしてガードする。 -const CODEREF_LINE_TOLERANCE = 10; - -// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 -// 比較・保存を揃えるため。 -function normalizeFilePath(fp: string): string { - const stripped = fp.startsWith('./') ? fp.slice(2) : fp; - return path.posix.normalize(stripped); -} - -async function findDuplicateCoderef( - store: ProjectStore, - filePath: string, - startLine: number, - codebaseId: string | undefined, -): Promise<{ id: string; startLine: number } | null> { - const all = await store.listNodes(); - for (const n of all) { - const rec = n as Record; - const type = rec.type as string | undefined; - const adoptAs = rec.adoptAs as string | undefined; - // 正規 coderef と、adoptAs=coderef の proposal の両方を対象にする。 - const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); - if (!isCoderef) continue; - const fp = rec.filePath as string | undefined; - const sl = rec.startLine as number | undefined; - if (!fp || typeof sl !== 'number') continue; - if (normalizeFilePath(fp) !== filePath) continue; - // マルチコードベース対応: 同一 filePath でも codebaseId が異なれば別物として扱う。 - // codebaseId 未指定の旧 proposal (レガシー) や横断エージェントは従来通り全件比較する。 - const existingCb = rec.codebaseId as string | undefined; - if (codebaseId !== undefined && existingCb !== undefined && existingCb !== codebaseId) { - continue; - } - if (Math.abs(sl - startLine) <= CODEREF_LINE_TOLERANCE) { - return { id: rec.id as string, startLine: sl }; - } - } - return null; -} - // adoptAs=question の options として有効な最小数。extract-questions の仕様上 // 「必ず 2〜4 個」とプロンプト指示しているが、AI が守らなかったとき proposal // 採用後に decision を選べない question が出来てしまうのでサーバ側でも弾く。 const QUESTION_MIN_OPTIONS = 2; +// 保存前の filePath 正規化 (guard 内の正規化とは独立、保存内容の整合性のため必須)。 +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に揃えて DB に書く。 +function normalizeFilePathForStorage(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + export function createNodeHandler(deps: CreateNodeDeps) { // 複数ノードが同じ anchor で作られたときに重ならないよう、呼び出しごとにオフセットをずらす。 // agent セッション毎に独立させるため handler closure で保持。 let nextOffsetIndex = 0; - // 同一セッション内で作成済みの question を「anchorId|正規化タイトル」の Set で記録する。 - // findRelatedNodes は edge 経由で近傍を引くため、「create_node × 2 → create_edge × 2」の - // 順にモデルが呼んだとき 1 件目の edge 作成前は 2 件目の findRelatedNodes が 1 件目を - // 拾えず重複が素通りする。セッション内 Set と併用して防ぐ。 - const sessionQuestionKeys = new Set(); + // duplicate-guards の sessionMemo (anchorId|title など、guard 実装が定義するキー)。 + // handler closure で持ち、同一エージェントセッション内の重複を短絡防止する。 + const sessionMemo = new Set(); + return async (input: unknown): Promise => { const parsed = CreateNodeInputSchema.safeParse(input); if (!parsed.success) { @@ -115,9 +88,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { } const { adoptAs, title, body, x, y, additional } = parsed.data; - // coderef のとき filePath を正規化して additional に戻し、さらに近接 coderef を探して重複ガード。 - // deps.codebaseId があれば additional に必ず注入し、adopt 時に codebaseId 必須検証が通るようにする。 let normalizedAdditional = additional; + + // coderef: filePath 正規化 + codebaseId 注入。 + // 保存値の正規化なので guard 委譲とは別に必須 (DB に "./" 付きを書かない)。 if (adoptAs === 'coderef') { const base = additional ?? {}; const withCb: Record = @@ -126,29 +100,14 @@ export function createNodeHandler(deps: CreateNodeDeps) { : { ...base }; const fp = withCb.filePath; if (typeof fp === 'string' && fp.length > 0) { - const normalized = normalizeFilePath(fp); - withCb.filePath = normalized; - const sl = withCb.startLine; - if (typeof sl === 'number') { - const activeCbId = - typeof withCb.codebaseId === 'string' ? (withCb.codebaseId as string) : undefined; - const dup = await findDuplicateCoderef(deps.store, normalized, sl, activeCbId); - if (dup) { - return { - ok: false, - output: `重複: ${dup.id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(dup.startLine - sl)})`, - }; - } - } + withCb.filePath = normalizeFilePathForStorage(fp); } normalizedAdditional = withCb; } - // adoptAs=question: options の正規化 + 有効数チェック + anchor 重複ガード。 + // adoptAs=question: options の正規化 + 有効数チェック。 // AI は { text } だけ渡す (仕様)。id / selected 指定があっても上書きする (信頼境界)。 // options < 2 件の proposal は「決定不能な question」になるのでサーバ側で弾く。 - // sessionKey は addNode 成功後に set へ追加する (失敗時の汚染回避)。 - let sessionKey: string | null = null; if (adoptAs === 'question') { const rawOptions = additional?.options; const normalizedOptions = Array.isArray(rawOptions) @@ -173,32 +132,22 @@ export function createNodeHandler(deps: CreateNodeDeps) { options: normalizedOptions, decision: null, }; - - // anchor の近傍に同タイトル question (正規 or proposal) があれば重複として弾く。 - // 比較は [AI] 接頭辞を剥がして揃える。 - const normalizedTitle = stripAiPrefix(title); - sessionKey = `${deps.anchorId}|${normalizedTitle}`; - if (sessionQuestionKeys.has(sessionKey)) { - return { - ok: false, - output: `重複 (同一セッション内): anchor ${deps.anchorId} に既に同タイトル question を生成済み`, - }; - } - const neighbors = await deps.store.findRelatedNodes(deps.anchorId); - const dup = neighbors.find((n) => { - const rec = n as unknown as { type: string; adoptAs?: string; title: string }; - const isQuestion = - rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); - return isQuestion && stripAiPrefix(rec.title) === normalizedTitle; - }); - if (dup) { - return { - ok: false, - output: `重複: anchor ${deps.anchorId} に既に同タイトル question 候補 ${(dup as { id: string }).id} が存在`, - }; - } } + // 重複ガード: dispatcher に委譲 (coderef / question / source-url の guard が登録済み)。 + // 重複あれば early return、無ければ addNode に進む。 + // codebaseId は exactOptionalPropertyTypes 対応で条件付きで含める。 + const guardCtx: DuplicateGuardContext = { + store: deps.store, + anchorId: deps.anchorId, + sessionMemo, + ...(deps.codebaseId !== undefined ? { codebaseId: deps.codebaseId } : {}), + }; + const guardInput = { title, body, additional: normalizedAdditional }; + const dup = await dispatchDuplicateGuard(adoptAs, guardInput, guardCtx); + if (dup) return { ok: false, output: dup.reason }; + + // 共通: ensureTitle / placement / addNode const ensuredTitle = title.startsWith('[AI]') ? title : `[AI] ${title}`; const idx = nextOffsetIndex++; const placedX = x ?? deps.anchor.x + 260 + idx * 20; @@ -216,7 +165,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { sourceAgentId: deps.agentName, } as Parameters[0])) as ProposalNode; deps.emit({ type: 'node_created', node: created }); - if (sessionKey) sessionQuestionKeys.add(sessionKey); + + // 生成成功後、guard に通知 (sessionMemo の更新など)。失敗時は通知しない (Set 汚染回避)。 + notifyCreated(adoptAs, guardInput, guardCtx); + return { ok: true, output: JSON.stringify(created) }; } catch (err) { return { ok: false, output: `addNode failed: ${String(err)}` }; diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index b2199ed..514617e 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -8,8 +8,10 @@ import { CodebaseSchema, CodeRefNodeSchema, EdgeSchema, + McpServerConfigSchema, NodeSchema, ProjectMetaSchema, + ProjectSchema, ProposalNodeSchema, QuestionNodeSchema, RequirementNodeSchema, @@ -266,6 +268,72 @@ describe('ChatBlockSchema', () => { }).success, ).toBe(true); }); + it('auth_request ブロック (pending)', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'pending', + }); + expect(r.success).toBe(true); + }); + it('auth_request ブロック (failed + failureMessage)', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'failed', + failureMessage: 'invalid_grant', + }); + expect(r.success).toBe(true); + }); + it('auth_request の authUrl が URL でないと reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'not-a-url', + status: 'pending', + }); + expect(r.success).toBe(false); + }); + // CodeRabbit 指摘 (PR #18): auth_request の status と failureMessage の整合を schema で固定。 + // failed なのに failureMessage 無し → reject。pending/completed に failureMessage が + // 付いている → reject。 + it('auth_request: failed に failureMessage 無しは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'failed', + }); + expect(r.success).toBe(false); + }); + it('auth_request: pending に failureMessage 付きは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'pending', + failureMessage: 'should not be here', + }); + expect(r.success).toBe(false); + }); + it('auth_request: completed に failureMessage 付きは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'completed', + failureMessage: 'should not be here', + }); + expect(r.success).toBe(false); + }); it('不正な type は reject', () => { expect(ChatBlockSchema.safeParse({ type: 'other', text: 'x' }).success).toBe(false); }); @@ -317,3 +385,380 @@ describe('ChatThreadSchema / ChatThreadMetaSchema', () => { ).toBe(true); }); }); + +describe('McpServerConfigSchema', () => { + // OAuth 2.1 採用後、Tally は url のみ持ち auth credentials は MCP/SDK に委譲する。 + // よって round-trip の最小形は id/name/kind/url/options のみ。 + it('atlassian round-trip (auth credentials は MCP/SDK 任せ、Tally は url のみ)', () => { + const raw = { + id: 'atlassian-cloud', + name: 'Atlassian Cloud', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('options 未指定なら default が入る', () => { + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + }); + expect(parsed.options.maxChildIssues).toBe(30); + expect(parsed.options.maxCommentsPerIssue).toBe(5); + }); + + it('url が URL でないと fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'not a url', + }), + ).toThrow(); + }); + + it('auth フィールドが付いていても strict ではないので無視される (passthrough)', () => { + // schema 上は auth キーを持たない。zod は default で strict ではないため余計なキーは drop。 + // OAuth 移行前の YAML が混入しても parse 自体は通すが、auth 情報は使われない。 + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, // 余計なキー + } as Record); + expect((parsed as unknown as { auth?: unknown }).auth).toBeUndefined(); + }); +}); + +describe('McpServerConfigSchema hardening', () => { + // hardening test の共通 valid base。テスト対象のフィールドだけを上書きする。 + const validBase = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + }; + + describe('url: https 強制 + loopback 例外', () => { + it('https スキームは pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'https://x.test/mcp' }), + ).not.toThrow(); + }); + + it('http://localhost は pass (セルフホスト MCP server 想定)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://localhost:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://127.0.0.1 は pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://127.0.0.1:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://example.com は fail (OAuth handshake / token を cleartext で運ばない)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://example.com/mcp' }), + ).toThrow(); + }); + + it('ftp:// は fail', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'ftp://x.test/mcp' }), + ).toThrow(); + }); + }); + + describe('id: charset 制約 (CodebaseSchema.id と同じ regex)', () => { + it("'atlassian' は pass", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'atlassian' })).not.toThrow(); + }); + + it("'atlassian-cloud' は pass", () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, id: 'atlassian-cloud' }), + ).not.toThrow(); + }); + + it("'a' は pass (1 文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a' })).not.toThrow(); + }); + + it("'Atlassian' は fail (大文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'Atlassian' })).toThrow(); + }); + + it("'1abc' は fail (数字始まり)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: '1abc' })).toThrow(); + }); + + it("'a_b' は fail (アンダースコア含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a_b' })).toThrow(); + }); + + it("'a.b' は fail (ドット含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a.b' })).toThrow(); + }); + + it('33 文字は fail (上限超過)', () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a'.repeat(33) })).toThrow(); + }); + }); +}); + +describe('ProjectSchema.mcpServers', () => { + it('mcpServers 未指定なら default の空配列', () => { + const p = ProjectSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + }); + expect(p.mcpServers).toEqual([]); + }); + + it('mcpServers 指定で round-trip する', () => { + const input = { + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + }, + ], + }; + const p = ProjectSchema.parse(input); + expect(p.mcpServers).toHaveLength(1); + expect(p.mcpServers[0]?.options.maxChildIssues).toBe(30); + expect(p.mcpServers[0]?.id).toBe('atlassian'); + }); +}); + +describe('ProjectMetaSchema.mcpServers', () => { + it('ProjectMetaSchema にも mcpServers が乗る (project.yaml の meta との整合)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + }, + ], + }); + expect(meta.mcpServers).toHaveLength(1); + }); + + it('既存 YAML (mcpServers 無し) は default [] で読める (後方互換)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }); + expect(meta.mcpServers).toEqual([]); + }); +}); + +describe('ChatBlockSchema.tool_use.source', () => { + it('source 未指定の古いデータが "internal" に defaults', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: { x: 1 }, + approval: 'approved', + }); + expect(b.type).toBe('tool_use'); + if (b.type === 'tool_use') expect(b.source).toBe('internal'); + }); + + it('source = "external" は承認不要 (approval optional)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-2', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + source: 'external', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBeUndefined(); + } + }); + + it('source = "external" + approval 指定もできる (任意で記録可)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-3', + name: 'mcp__atlassian__jira_search', + input: {}, + source: 'external', + approval: 'approved', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBe('approved'); + } + }); + + it('source = "internal" で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-4', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + }), + ).toThrow(); + }); + + it('source 未指定 (= internal default) で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-5', + name: 'mcp__tally__create_node', + input: {}, + }), + ).toThrow(); + }); + + it('既存の internal + approval=pending/approved/rejected は引き続き valid', () => { + for (const a of ['pending', 'approved', 'rejected'] as const) { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: `tu-${a}`, + name: 'mcp__tally__create_node', + input: {}, + approval: a, + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('internal'); + expect(b.approval).toBe(a); + } + } + }); + + it('source の不正値 (例 "auto") は fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-bad', + name: 'mcp__tally__create_node', + input: {}, + source: 'auto', + approval: 'approved', + }), + ).toThrow(); + }); +}); + +describe('RequirementNodeSchema.sourceUrl', () => { + it('sourceUrl 未指定は optional (既存互換)', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + }); + expect(n.sourceUrl).toBeUndefined(); + }); + + it('sourceUrl 指定で保持', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が URL でないと fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'not a url', + }), + ).toThrow(); + }); + + it('sourceUrl が http:// なら fail (https 強制、UI link でも cleartext は禁止)', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'http://jira.test/browse/EPIC-1', + }), + ).toThrow(); + }); + + it('sourceUrl が https:// なら pass', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が ftp:// なら fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'ftp://jira.test/EPIC-1', + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 849e3c9..ce52111 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -53,6 +53,23 @@ export const RequirementNodeSchema = z.object({ kind: z.enum(REQUIREMENT_KINDS).optional(), qualityCategory: z.enum(QUALITY_CATEGORIES).optional(), priority: z.enum(REQUIREMENT_PRIORITIES).optional(), + // 外部 MCP (Atlassian 等) から取り込んだ場合の元情報 URL。Phase 6+ で UI から開けるようにする予定。 + // UI link 経由で credential が漏れる構図を排除するため https-only。 + // McpServerConfig.url と異なり loopback 例外は不要 (Jira issue URL に loopback はあり得ない)。 + sourceUrl: z + .string() + .url() + .refine( + (u) => { + try { + return new URL(u).protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'sourceUrl は https で始まる必要があります' }, + ) + .optional(), }); export const UseCaseNodeSchema = z.object({ @@ -174,6 +191,73 @@ function checkUniqueCodebaseIds( } } +// --------------------------------------------------------------------------- +// MCP サーバー設定スキーマ (Atlassian MCP 連携) +// --------------------------------------------------------------------------- +// 注: ProjectMetaSchema / ProjectSchema が McpServerConfigSchema を参照するため、 +// 宣言順序として MCP セクションを Project 系より前に置く。 +// +// 認証方針 (Premise 9 撤回後): +// MCP プロトコルの OAuth 2.1 を採用し、Tally は credentials を一切扱わない。 +// - 401 を受けたら Claude Agent SDK が WWW-Authenticate から OAuth metadata を取り、 +// ブラウザ経由 (or device flow) で auth、token 管理は SDK 側で完結する。 +// - Tally は url のみ持ち、Authorization header は組み立てない。 +// - PAT / API key を Tally のメモリ・ログ・ファイルに残さない。 +// 既存の sooperset/mcp-atlassian (PAT 認証) を使う場合はその MCP server 自身が +// 起動時 env で credentials を持つ前提 (Tally は header passthrough しない)。 + +// options は未指定時に {} を default として与え、内側で各フィールドの default を発火させる。 +// zod v4 では outer .default(value) が parse 前に value をそのまま流すため、 +// 入力と同じ経路でフィールド default を解決するには .default({}) → inner default の 2 段構え。 +const McpServerOptionsSchema = z + .object({ + maxChildIssues: z.number().int().positive().default(30), + maxCommentsPerIssue: z.number().int().nonnegative().default(5), + }) + .default(() => ({ maxChildIssues: 30, maxCommentsPerIssue: 5 })); + +// MCP サーバー id は SDK の wildcard `mcp____*` の id 部分に embed されるため、 +// tool 名 matching が壊れないよう CodebaseSchema.id と同じ charset 制約を採用。 +const McpServerIdRegex = /^[a-z][a-z0-9-]{0,31}$/u; + +export const McpServerConfigSchema = z.object({ + id: z.string().regex(McpServerIdRegex, { + message: 'mcp server id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', + }), + name: z.string().min(1), + kind: z.literal('atlassian'), + // OAuth 2.1 / その他 credential は MCP プロトコル経由で SDK が処理する。 + // ここでは url のみを持ち、cleartext を防ぐため https のみ許可 + // (開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容)。 + url: z + .string() + .url() + .refine( + (u) => { + try { + const parsed = new URL(u); + if (parsed.protocol === 'https:') return true; + if ( + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1' || + parsed.hostname === '[::1]') + ) { + return true; + } + return false; + } catch { + return false; + } + }, + { message: 'url は https で始まる必要があります (loopback の http は例外的に許容)' }, + ), + options: McpServerOptionsSchema, +}); + +export type McpServerConfig = z.infer; + // .tally/project.yaml に対応する meta のみのスキーマ。 // ノード・エッジはファイル分割で永続化するため、ここには含めない。 export const ProjectMetaSchema = z @@ -183,6 +267,8 @@ export const ProjectMetaSchema = z description: z.string().optional(), // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。既存 YAML (フィールド無し) は default [] で読み込める。 + mcpServers: z.array(McpServerConfigSchema).default([]), createdAt: z.string(), updatedAt: z.string(), }) @@ -195,6 +281,8 @@ export const ProjectSchema = z name: z.string().min(1), description: z.string().optional(), codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。ProjectMetaSchema と整合。 + mcpServers: z.array(McpServerConfigSchema).default([]), createdAt: z.string(), updatedAt: z.string(), nodes: z.array(NodeSchema), @@ -202,12 +290,13 @@ export const ProjectSchema = z }) .superRefine((p, ctx) => checkUniqueCodebaseIds(p.codebases, ctx)); -// PATCH /api/projects/:id の body スキーマ。codebases 全置換のみ許可(部分更新はしない)。 +// PATCH /api/projects/:id の body スキーマ。codebases / mcpServers は全置換のみ (部分更新はしない)。 export const ProjectMetaPatchSchema = z .object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), codebases: z.array(CodebaseSchema).optional(), + mcpServers: z.array(McpServerConfigSchema).optional(), }) .strict() .superRefine((patch, ctx) => checkUniqueCodebaseIds(patch.codebases, ctx)); @@ -218,19 +307,59 @@ export const ProjectMetaPatchSchema = z export const ChatBlockSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ - type: z.literal('tool_use'), - toolUseId: z.string().min(1), - name: z.string().min(1), - input: z.unknown(), - approval: z.enum(['pending', 'approved', 'rejected']), - }), + z + .object({ + type: z.literal('tool_use'), + toolUseId: z.string().min(1), + name: z.string().min(1), + input: z.unknown(), + // 'internal' = Tally MCP (人間承認が必要)、'external' = Atlassian 等の外部 MCP (承認概念なし)。 + // 既存 YAML (source 無し) は default 'internal' で読めるよう後方互換を保つ。 + source: z.enum(['internal', 'external']).default('internal'), + approval: z.enum(['pending', 'approved', 'rejected']).optional(), + }) + .refine((b) => b.source === 'external' || b.approval !== undefined, { + message: 'internal tool_use には approval が必要', + }), z.object({ type: z.literal('tool_result'), toolUseId: z.string().min(1), ok: z.boolean(), output: z.string(), }), + // 外部 MCP (Atlassian 等) の OAuth 2.1 認証フローを 1 等地で扱うブロック。 + // SDK の `mcp____authenticate` tool_use を生のまま並べると UX が破綻する + // (URL がプレーンテキスト + redirect 先 localhost:XXXXX が即死) ため、検出して + // この auth_request に置き換える。status は同 thread 内の complete_authentication で更新。 + // mcpServerLabel は project.mcpServers[].label 由来 (label 未設定なら id を表示)。 + // failureMessage は status='failed' のときだけ持つ。superRefine で永続化フォーマット + // としての不正状態 (failed なのに message 無し / pending・completed に message が付く) + // を弾く (CodeRabbit 指摘 PR #18)。 + z + .object({ + type: z.literal('auth_request'), + mcpServerId: z.string().min(1), + mcpServerLabel: z.string().min(1), + authUrl: z.string().url(), + status: z.enum(['pending', 'completed', 'failed']), + failureMessage: z.string().optional(), + }) + .superRefine((b, ctx) => { + if (b.status === 'failed' && !b.failureMessage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'failed auth_request には failureMessage が必要', + path: ['failureMessage'], + }); + } + if (b.status !== 'failed' && b.failureMessage !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'failureMessage は failed のときだけ設定できます', + path: ['failureMessage'], + }); + } + }), ]); export const ChatMessageSchema = z.object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bc9dcdf..0bc7a08 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,7 +20,7 @@ import type { UserStoryNodeSchema, } from './schema'; -export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta } from './schema'; +export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta, McpServerConfig } from './schema'; export type NodeType = (typeof NODE_TYPES)[number]; export type EdgeType = (typeof EDGE_TYPES)[number]; diff --git a/packages/frontend/README.md b/packages/frontend/README.md index ef45ca7..3de95fc 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -59,7 +59,7 @@ src/ ## 開発 ```bash -pnpm --filter @tally/frontend dev # http://localhost:3000 +pnpm --filter @tally/frontend dev # http://localhost:3321 pnpm --filter @tally/frontend build pnpm --filter @tally/frontend typecheck ``` diff --git a/packages/frontend/next-env.d.ts b/packages/frontend/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/packages/frontend/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 800a9bf..af2bb1b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Tally のフロントエンド (Next.js 16 App Router + React Flow キャンバス)", "scripts": { - "dev": "next dev", + "dev": "next dev -p 3321", "build": "next build", - "start": "next start", + "start": "next start -p 3321", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts index 97a731f..b27f84a 100644 --- a/packages/frontend/playwright.config.ts +++ b/packages/frontend/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ globalSetup: './e2e/global-setup.ts', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:3321', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, @@ -31,7 +31,7 @@ export default defineConfig({ // frontend の dev server を自動起動。ai-engine は未起動でもノード表示は動く (chat を開かない限り WS 接続なし)。 webServer: { command: 'pnpm dev', - url: 'http://localhost:3000', + url: 'http://localhost:3321', reuseExistingServer: !process.env.CI, timeout: 120_000, env: { diff --git a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts index 4d1e706..694b296 100644 --- a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts @@ -22,6 +22,7 @@ describe('/api/projects/[id]/chats', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts index c2e639d..f55715f 100644 --- a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts @@ -24,6 +24,7 @@ describe('POST /api/projects/[id]/edges', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -98,6 +99,7 @@ describe('PATCH /api/projects/[id]/edges/[edgeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts index b3b0090..4b78d78 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts @@ -21,6 +21,7 @@ describe('POST /api/projects/[id]/nodes/[nodeId]/adopt', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts index 84cfc7c..066c602 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts @@ -22,6 +22,7 @@ describe('POST /api/projects/[id]/nodes', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -91,6 +92,7 @@ describe('PATCH /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -195,6 +197,7 @@ describe('DELETE /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/route.test.ts b/packages/frontend/src/app/api/projects/[id]/route.test.ts index 04861b5..170d61c 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -70,6 +70,85 @@ describe('PATCH /api/projects/:id', () => { expect(body.codebases).toEqual([{ id: 'web', label: 'Web', path: '/w' }]); }); + it('mcpServers[] を全置換 (Task 16)', async () => { + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian', + url: 'https://x.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: Array<{ id: string }> }; + expect(body.mcpServers).toHaveLength(1); + expect(body.mcpServers[0]?.id).toBe('atlassian'); + }); + + it('mcpServers の url が http (loopback 以外) なら 400 (Task 1 hardening)', async () => { + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'http://example.com/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(400); + }); + + it('mcpServers を空配列で全消去できる', async () => { + // 事前に登録 (失敗していると後続の「空配列で削除」が空 → 空 で偽の成功になる + // ので、ここで 200 を assert しておく)。CodeRabbit 指摘 PR #18。 + const setupRes = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(setupRes.status).toBe(200); + // 空配列で全消去 + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ mcpServers: [] }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: unknown[] }; + expect(body.mcpServers).toEqual([]); + }); + it('name を更新', async () => { const res = await PATCH( new Request('http://localhost', { diff --git a/packages/frontend/src/app/api/projects/[id]/route.ts b/packages/frontend/src/app/api/projects/[id]/route.ts index db983ee..4d0c4f3 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.ts @@ -84,6 +84,7 @@ export async function PATCH(req: Request, context: RouteContext): Promise { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + const pendingBlock = { + type: 'auth_request' as const, + mcpServerId: 'atlassian', + mcpServerLabel: 'My Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', + status: 'pending' as const, + }; + + it('pending: ラベル / 認証ボタン / paste 入力欄が表示される', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render(); + expect(screen.getByText(/My Atlassian 認証/)).toBeInTheDocument(); + expect(screen.getByText(/未認証/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /callback URL/ })).toBeInTheDocument(); + }); + + it('「認証」ボタンクリックで authUrl を新規タブで開く (window.open)', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + render(); + fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); + expect(openSpy).toHaveBeenCalledWith(pendingBlock.authUrl, '_blank', 'noopener,noreferrer'); + openSpy.mockRestore(); + }); + + it('callback URL 入力 → 認証完了で sendChatMessage に mcpServerId 付き user_message を送る', async () => { + const send = vi.fn().mockResolvedValue(undefined); + useCanvasStore.setState({ sendChatMessage: send } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { + target: { value: 'http://localhost:54801/callback?code=AAA&state=xyz' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^認証完了$/ })); + // sendChatMessage 呼び出し待ち + await screen.findByDisplayValue(''); // 送信成功時は input がクリアされる + expect(send).toHaveBeenCalledTimes(1); + const text = send.mock.calls[0]?.[0] as string; + expect(text).toContain('[OAuth callback for atlassian]'); + expect(text).toContain('http://localhost:54801/callback?code=AAA&state=xyz'); + expect(text).toContain('My Atlassian'); + }); + + it('callback URL の形式が不正なら認証完了ボタンが disabled (送信されない)', () => { + const send = vi.fn(); + useCanvasStore.setState({ sendChatMessage: send } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'not a url' } }); + const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + fireEvent.click(btn); + expect(send).not.toHaveBeenCalled(); + }); + + it('host が localhost / 127.0.0.1 でない URL は reject (paste 偽造の防御)', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { + target: { value: 'http://evil.example.com/callback?code=AAA&state=xyz' }, + }); + const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('completed: 完了メッセージを表示し paste 欄は出ない', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render( + , + ); + expect(screen.getByText(/認証済/)).toBeInTheDocument(); + expect(screen.getByText(/認証完了/)).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /callback URL/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /My Atlassian で認証/ })).toBeNull(); + }); + + it('failed: 失敗メッセージと failureMessage 内容を表示', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render( + , + ); + expect(screen.getAllByText(/失敗/).length).toBeGreaterThan(0); + expect(screen.getByText(/invalid_grant: state mismatch/)).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/src/components/chat/auth-request-card.tsx b/packages/frontend/src/components/chat/auth-request-card.tsx new file mode 100644 index 0000000..7e57b53 --- /dev/null +++ b/packages/frontend/src/components/chat/auth-request-card.tsx @@ -0,0 +1,224 @@ +'use client'; + +import type { ChatBlock } from '@tally/core'; +import { useState } from 'react'; + +import { useCanvasStore } from '@/lib/store'; + +type AuthRequestBlock = Extract; + +// callback URL が「http://localhost:XXXXX/callback?code=...&state=...」形式かを軽く検査。 +// SDK が立てた一時 callback 鯖は agent turn 終了で死ぬので、ユーザーがアドレスバーから +// コピーして貼ることを想定。host は localhost / 127.0.0.1 のみ通す。 +function isLikelyCallbackUrl(s: string): boolean { + try { + const u = new URL(s.trim()); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; + if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return false; + return u.searchParams.has('code') && u.searchParams.has('state'); + } catch { + return false; + } +} + +// 外部 MCP の OAuth 2.1 認証要求ブロック。 +// 「Atlassian で認証」ボタン (新規タブ) と callback URL paste 入力欄を 1 等地でまとめる。 +// 設計意図: SDK が tool 出力した auth URL をプレーンテキスト中に紛れさせると、 +// ・URL がクリックできない / 同タブ遷移で session を壊す +// ・redirect 先 localhost:XXXXX が即死しているのにユーザーが原因を特定できない +// という UX が破綻するため、専用カードで「やるべきこと」を 2 ステップに分けて提示する。 +export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { + const sendChatMessage = useCanvasStore((s) => s.sendChatMessage); + const [callbackUrl, setCallbackUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const isPending = block.status === 'pending'; + const isCompleted = block.status === 'completed'; + const isFailed = block.status === 'failed'; + + const onAuthClick = () => { + if (!isPending) return; + window.open(block.authUrl, '_blank', 'noopener,noreferrer'); + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = callbackUrl.trim(); + if (!trimmed || !isLikelyCallbackUrl(trimmed)) return; + setSubmitting(true); + try { + // AI に complete_authentication を呼ばせるための user_message。 + // mcpServerId を明示することで、複数 MCP server がある場合でも AI が正しい + // server の complete_authentication ツールを選べる。 + const text = + `[OAuth callback for ${block.mcpServerId}] ${trimmed}\n\n` + + `上記 URL を使って ${block.mcpServerLabel} の認証コードを引き換えて、` + + `元のタスクを続行してください。`; + await sendChatMessage(text); + setCallbackUrl(''); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ 🔐 {block.mcpServerLabel} 認証 + {statusLabel(block.status)} +
+ + {isPending && ( + <> +
+ 下のボタンで {block.mcpServerLabel} の認証ページを別タブで開いて承認してください。 +
+ 承認後にブラウザが「接続できません」を表示しても問題ありません。 +
+ アドレスバーの URL (例: http://localhost:XXXXX/callback?code=...) を + コピーして、下の入力欄に貼り付け「認証完了」を押してください。 +
+ +
+ setCallbackUrl(e.target.value)} + placeholder="http://localhost:XXXXX/callback?code=...&state=..." + style={INPUT_STYLE} + disabled={submitting} + aria-label="callback URL" + /> + +
+ + )} + + {isCompleted && ( +
+ ✅ 認証完了。{block.mcpServerLabel} のツールが利用可能になりました。 +
+ )} + + {isFailed && ( +
+ ❌ 認証に失敗しました。 + {block.failureMessage ? ( +
{block.failureMessage}
+ ) : null} +
+ 再度 AI に認証を要求してください (例: 「もう一度認証して」)。 +
+ )} +
+ ); +} + +function statusLabel(status: AuthRequestBlock['status']): string { + if (status === 'pending') return '未認証'; + if (status === 'completed') return '認証済'; + return '失敗'; +} + +function badgeStyle(status: AuthRequestBlock['status']) { + if (status === 'completed') { + return { ...BADGE_BASE_STYLE, background: '#23863633', color: '#7ee787' }; + } + if (status === 'failed') { + return { ...BADGE_BASE_STYLE, background: '#f8514933', color: '#ffa198' }; + } + return { ...BADGE_BASE_STYLE, background: '#bf8700aa', color: '#ffd33d' }; +} + +const CARD_STYLE = { + background: '#1a1f2e', + border: '1px solid #58a6ff', + borderRadius: 6, + padding: 10, + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + width: '100%', +}; +const HEADER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 13, + color: '#e6edf3', +}; +const LABEL_STYLE = { + flex: 1, + fontWeight: 600, +}; +const BADGE_BASE_STYLE = { + fontSize: 10, + padding: '1px 6px', + borderRadius: 4, +}; +const DESC_STYLE = { + fontSize: 11, + color: '#c8d1da', + lineHeight: 1.5, +}; +const COMPLETED_DESC_STYLE = { + fontSize: 12, + color: '#7ee787', +}; +const FAILED_DESC_STYLE = { + fontSize: 11, + color: '#ffa198', + lineHeight: 1.5, +}; +const FAILURE_PRE_STYLE = { + background: '#0d1117', + border: '1px solid #30363d', + borderRadius: 4, + padding: 6, + fontSize: 10, + fontFamily: 'ui-monospace, SFMono-Regular, monospace', + color: '#ffa198', + marginTop: 4, + whiteSpace: 'pre-wrap' as const, +}; +const AUTH_BUTTON_STYLE = { + background: '#1f6feb', + color: '#fff', + border: '1px solid #388bfd', + borderRadius: 6, + padding: '8px 12px', + fontSize: 12, + fontWeight: 600, + cursor: 'pointer', +}; +const FORM_STYLE = { + display: 'flex', + gap: 6, +}; +const INPUT_STYLE = { + flex: 1, + background: '#0d1117', + border: '1px solid #30363d', + borderRadius: 4, + padding: '6px 8px', + fontSize: 11, + fontFamily: 'ui-monospace, SFMono-Regular, monospace', + color: '#e6edf3', +}; +const SUBMIT_BUTTON_STYLE = { + background: '#238636', + color: '#fff', + border: '1px solid #2ea043', + borderRadius: 6, + padding: '4px 10px', + fontSize: 11, + cursor: 'pointer', +}; diff --git a/packages/frontend/src/components/chat/chat-message.tsx b/packages/frontend/src/components/chat/chat-message.tsx index 303e0aa..0f78456 100644 --- a/packages/frontend/src/components/chat/chat-message.tsx +++ b/packages/frontend/src/components/chat/chat-message.tsx @@ -2,6 +2,7 @@ import type { ChatBlock, ChatMessage as ChatMessageType } from '@tally/core'; +import { AuthRequestCard } from './auth-request-card'; import { ToolApprovalCard } from './tool-approval-card'; interface Props { @@ -55,6 +56,9 @@ function renderBlock(block: ChatBlock, idx: number) { ); } + if (block.type === 'auth_request') { + return ; + } return null; } diff --git a/packages/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx index 653f286..5f13f1e 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx @@ -20,6 +20,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-abc', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }} />, @@ -38,6 +39,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-xyz', name: 'mcp__tally__create_edge', input: { from: 'a', to: 'b', type: 'derive' }, + source: 'internal', approval: 'pending', }} />, @@ -55,10 +57,49 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: {}, + source: 'internal', approval: 'pending', }} />, ); expect(screen.getByText(/^create_node$/)).toBeDefined(); }); + + it('source=external の tool_use は承認 / 却下ボタンを表示しない (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + render( + , + ); + expect(screen.queryByRole('button', { name: /^承認$/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /^却下$/ })).toBeNull(); + // 外部ソース表示は AI が読んだ tool 名を含む + expect(screen.getByText(/外部ソース/)).toBeInTheDocument(); + expect(screen.getByText(/atlassian: jira_get_issue/)).toBeInTheDocument(); + }); + + it('source=external は折り畳み (details) で input を隠す (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + const { container } = render( + , + ); + //
要素が存在 + const details = container.querySelector('details'); + expect(details).not.toBeNull(); + }); }); diff --git a/packages/frontend/src/components/chat/tool-approval-card.tsx b/packages/frontend/src/components/chat/tool-approval-card.tsx index 838b500..93ebeef 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.tsx @@ -6,9 +6,28 @@ import { useCanvasStore } from '@/lib/store'; type PendingToolBlock = Extract; -// pending tool_use のカード UI。承認 / 却下ボタン。 +// tool_use のカード UI。 +// - source='internal' (Tally MCP): 承認 / 却下 ボタン (approval=pending のとき) +// - source='external' (外部 MCP): 承認概念なし、AI が自律で読んだ外部ソースを折り畳み表示 export function ToolApprovalCard({ block }: { block: PendingToolBlock }) { const approveChatTool = useCanvasStore((s) => s.approveChatTool); + + // Task 18: 外部 MCP は source='external' で識別、承認 UI を出さない + if (block.source === 'external') { + const shortName = block.name.replace(/^mcp__([^_]+)__/, '$1: '); + const inputPreview = previewInput(block.input); + return ( +
+
+ + 🔗 外部ソース {shortName} + +
{inputPreview}
+
+
+ ); + } + const shortName = block.name.replace(/^mcp__tally__/, ''); const inputPreview = previewInput(block.input); @@ -113,3 +132,18 @@ const APPROVE_BUTTON_STYLE = { fontSize: 11, cursor: 'pointer', }; +const EXTERNAL_CARD_STYLE = { + background: '#0d1d2a', + border: '1px solid #1f6feb', + borderRadius: 6, + padding: 8, + width: '100%', +}; +const EXTERNAL_HEADER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: '#79c0ff', + cursor: 'pointer', +}; diff --git a/packages/frontend/src/components/details/usecase-detail.test.tsx b/packages/frontend/src/components/details/usecase-detail.test.tsx index 983e9f4..c4ecf3f 100644 --- a/packages/frontend/src/components/details/usecase-detail.test.tsx +++ b/packages/frontend/src/components/details/usecase-detail.test.tsx @@ -38,6 +38,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], @@ -56,6 +57,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 58cabcc..b15b91a 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -10,6 +10,7 @@ const meta: ProjectMeta = { id: 'proj-a', name: 'P', codebases: [{ id: 'web', label: 'Web', path: '/w' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }; @@ -92,6 +93,92 @@ describe('ProjectSettingsDialog', () => { ); }); + it('MCP サーバーを追加できる (Task 17、OAuth 2.1 採用後は url のみ)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 新規追加された MCP server の id 入力欄が現れる (default は atlassian-1) + const idInput = screen.getByLabelText('mcp-0-id') as HTMLInputElement; + expect(idInput).toBeInTheDocument(); + expect(idInput.value).toBe('atlassian-1'); + // url のみ入力 (auth は MCP/SDK 任せ) + const urlInput = screen.getByLabelText('mcp-0-url'); + await userEvent.type(urlInput, 'https://x.test/mcp'); + + await userEvent.click(screen.getByRole('button', { name: /保存/ })); + await waitFor(() => + expect(patchProjectMeta).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: [ + expect.objectContaining({ + id: 'atlassian-1', + kind: 'atlassian', + url: 'https://x.test/mcp', + }), + ], + }), + ), + ); + // auth フィールドは保存ペイロードに含まれない + const lastCallArg = patchProjectMeta.mock.calls.at(-1)?.[0] as + | { mcpServers?: Array> } + | undefined; + expect(lastCallArg?.mcpServers?.[0]?.auth).toBeUndefined(); + }); + + it('MCP サーバーを削除できる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 追加直後 1 件 + expect(screen.getByLabelText('mcp-0-id')).toBeInTheDocument(); + // MCP セクションの削除 button (codebase の削除と区別するため scope で取る) + // codebase 削除 + MCP 削除の 2 つ「削除」button があるので getAllByRole で取って最後を click + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // 最後の削除 button = MCP のもの (codebase は 1 件目で追加 = 0 番目) + await userEvent.click(removeButtons[removeButtons.length - 1] as HTMLElement); + expect(screen.queryByLabelText('mcp-0-id')).toBeNull(); + }); + + // CodeRabbit 指摘 (PR #18): mcpServers.length + 1 で id 採番すると、 + // 削除→追加で既存 id と衝突する。未使用 suffix を採番するよう修正済み。 + it('追加 → 追加 → 1 件目を削除 → 追加で id が衝突しない', async () => { + render( {}} />); + const addBtn = () => screen.getByRole('button', { name: /MCP サーバーを追加/ }); + await userEvent.click(addBtn()); // atlassian-1 + await userEvent.click(addBtn()); // atlassian-2 + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-1'); + expect((screen.getByLabelText('mcp-1-id') as HTMLInputElement).value).toBe('atlassian-2'); + // 1 件目 (mcp-0) を削除 → 残るのは元 mcp-1 だが index は 0 にスライド + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // codebase 削除 (0) + MCP 2 件分 削除 (1, 2) → MCP 削除は最後 2 つ。1 件目 MCP を削除。 + await userEvent.click(removeButtons[removeButtons.length - 2] as HTMLElement); + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-2'); + // ここで再度追加 → 旧実装は mcpServers.length + 1 = 2 で `atlassian-2` 衝突。 + // 修正後は未使用 suffix `atlassian-1` が採番される。 + await userEvent.click(addBtn()); + const ids = [ + (screen.getByLabelText('mcp-0-id') as HTMLInputElement).value, + (screen.getByLabelText('mcp-1-id') as HTMLInputElement).value, + ]; + expect(new Set(ids).size).toBe(2); // 衝突なし + expect(ids).toContain('atlassian-2'); + expect(ids).toContain('atlassian-1'); + }); + + it('auth / secret 関連の入力欄は無い (OAuth 2.1 で MCP/SDK 任せ)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // auth scheme dropdown / envVar 入力欄 / secret 値入力欄、いずれも無い + expect(screen.queryByLabelText('mcp-0-scheme')).toBeNull(); + expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); + expect(screen.queryByLabelText('mcp-0-tokenEnvVar')).toBeNull(); + expect(screen.queryByLabelText(/PAT$/i)).toBeNull(); + expect(screen.queryByLabelText(/シークレット/i)).toBeNull(); + expect(screen.queryByLabelText(/api_token$/i)).toBeNull(); + expect(screen.queryByLabelText(/password/i)).toBeNull(); + // OAuth/MCP 任せの説明文言 + expect(screen.getByText(/MCP プロトコル/)).toBeInTheDocument(); + }); + it('id 重複時は保存 disabled', async () => { render( {}} />); // まず 2 件目を追加 diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index b19a8a9..ba57f6b 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -1,23 +1,38 @@ 'use client'; -import type { Codebase } from '@tally/core'; +import type { Codebase, McpServerConfig } from '@tally/core'; import { useEffect, useMemo, useState } from 'react'; import { TextInput } from '@/components/ui/text-input'; import { useCanvasStore } from '@/lib/store'; import { FolderBrowserDialog } from './folder-browser-dialog'; +// MCP サーバー新規追加時のデフォルト config (url 空、認証は MCP/SDK 任せ)。 +function makeDefaultMcpServer(seq: number): McpServerConfig { + return { + id: `atlassian-${seq}`, + name: 'Atlassian', + kind: 'atlassian', + url: '', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; +} + export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const projectMeta = useCanvasStore((s) => s.projectMeta); const patchProjectMeta = useCanvasStore((s) => s.patchProjectMeta); const [codebases, setCodebases] = useState([]); + const [mcpServers, setMcpServers] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (open && projectMeta) setCodebases(projectMeta.codebases); + if (open && projectMeta) { + setCodebases(projectMeta.codebases); + setMcpServers(projectMeta.mcpServers ?? []); + } }, [open, projectMeta]); const duplicateIds = useMemo(() => { @@ -58,7 +73,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setBusy(true); setError(null); try { - await patchProjectMeta({ codebases }); + await patchProjectMeta({ codebases, mcpServers }); onClose(); } catch (e) { setError(String((e as Error).message ?? e)); @@ -66,6 +81,26 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos } }; + const addMcpServer = () => { + // 削除→追加で `mcpServers.length + 1` を使うと既存 ID と衝突する + // (例: atlassian-1, atlassian-2 で 1 件削除して追加すると再び atlassian-2)。 + // mcpServers の id は下流で key として使われるため、未使用 suffix を探す。 + const usedIds = new Set(mcpServers.map((s) => s.id)); + let seq = 1; + while (usedIds.has(`atlassian-${seq}`)) seq += 1; + setMcpServers([...mcpServers, makeDefaultMcpServer(seq)]); + }; + + const updateMcpServer = (index: number, next: McpServerConfig) => { + const list = [...mcpServers]; + list[index] = next; + setMcpServers(list); + }; + + const removeMcpServer = (index: number) => { + setMcpServers(mcpServers.filter((_, i) => i !== index)); + }; + return (
@@ -131,6 +166,66 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
+
+
+ MCP サーバー (Atlassian 等の外部連携) ({mcpServers.length}) + +
+
+ 認証 (OAuth 2.1 / API token 等) は MCP プロトコルに任せます。Tally では URL + の登録のみ行い、 初回利用時に MCP サーバーから案内される認証フローに従ってください。 +
+ {mcpServers.length === 0 &&
MCP サーバー未設定
} +
    + {mcpServers.map((s, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: id 重複 (default 'atlassian-1' が複数行に並ぶ瞬間) を許す UI で index 込みのキーが必要 +
  • +
    + updateMcpServer(i, { ...s, id: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-id`} + style={{ ...INPUT, width: 160 }} + placeholder="atlassian" + /> + updateMcpServer(i, { ...s, name: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-name`} + style={{ ...INPUT, flex: 1 }} + placeholder="表示名" + /> + +
    +
    + updateMcpServer(i, { ...s, url: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-url`} + style={{ ...INPUT, flex: 1 }} + placeholder="https://mcp.atlassian.example/v1/mcp" + /> +
    +
  • + ))} +
+
+ {error && (
{error} @@ -227,6 +322,21 @@ const CB_ITEM = { gap: 6, flexWrap: 'wrap' as const, }; +const MCP_ITEM = { + display: 'flex', + flexDirection: 'column' as const, + gap: 4, + padding: 8, + border: '1px solid #30363d', + borderRadius: 6, + background: '#0d1117', +}; +const MCP_ROW = { + display: 'flex', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap' as const, +}; const CB_PATH = { flex: 1, fontSize: 11, diff --git a/packages/frontend/src/lib/store.test.ts b/packages/frontend/src/lib/store.test.ts index f676521..4e59705 100644 --- a/packages/frontend/src/lib/store.test.ts +++ b/packages/frontend/src/lib/store.test.ts @@ -17,6 +17,7 @@ function baseProject(): Project { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: now, updatedAt: now, nodes: [n1], @@ -136,6 +137,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -181,6 +183,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -250,6 +253,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -307,6 +311,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -370,6 +375,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -409,6 +415,7 @@ describe('useCanvasStore', () => { id: 'proj-2', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -432,6 +439,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -459,6 +467,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'old', label: 'Old', path: '/old' }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -487,6 +496,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -525,6 +535,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -680,6 +691,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -841,6 +853,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -890,6 +903,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -944,6 +958,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -989,6 +1004,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'req-a', type: 'requirement', x: 0, y: 0, title: 'A', body: '' }], diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index a72af75..4f6faf3 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -9,6 +9,7 @@ import type { Codebase, Edge, EdgeType, + McpServerConfig, Node, NodeType, Project, @@ -113,6 +114,7 @@ interface CanvasState { name?: string; description?: string | null; codebases?: Codebase[]; + mcpServers?: McpServerConfig[]; }) => Promise; // Phase 6: チャットスレッド管理。 @@ -277,6 +279,7 @@ export const useCanvasStore = create((set, get) => { toolUseId: evt.toolUseId, name: evt.name, input: evt.input, + source: 'internal', approval: 'pending', }, ], @@ -335,6 +338,100 @@ export const useCanvasStore = create((set, get) => { } return; } + // Task 12/18: 外部 MCP の tool_use を source='external' で append。承認 UI は出さない。 + if (evt.type === 'chat_tool_external_use') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_use', + toolUseId: evt.toolUseId, + name: evt.name, + input: evt.input, + source: 'external', + }, + ], + }; + }), + }); + return; + } + // Task 12/18: 外部 MCP の tool_result を append。 + // event はフル output (Task 13 の truncate は永続化のみ)。UI セッション内では全文閲覧可。 + if (evt.type === 'chat_tool_external_result') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_result', + toolUseId: evt.toolUseId, + ok: evt.ok, + output: evt.output, + }, + ], + }; + }), + }); + return; + } + // OAuth 2.1 認証要求/状態更新。pending は新規 auth_request ブロックを append、 + // completed/failed は同 mcpServerId の最新 pending ブロックを in-place で更新する。 + // これは chat-runner が永続化側で行う処理と整合させるための鏡映ロジック。 + if (evt.type === 'chat_auth_request') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + // pending: 該当 messageId に新規 append + if (evt.status === 'pending') { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'auth_request', + mcpServerId: evt.mcpServerId, + mcpServerLabel: evt.mcpServerLabel, + authUrl: evt.authUrl, + status: 'pending', + }, + ], + }; + } + // completed/failed: 同 mcpServerId の pending ブロックを更新 (どのメッセージに属していても)。 + // 同 server の pending は最新 1 件しか存在しないので最初に見つけたものを書き換える。 + let updated = false; + const blocks = m.blocks.map((b) => { + if ( + !updated && + b.type === 'auth_request' && + b.mcpServerId === evt.mcpServerId && + b.status === 'pending' + ) { + updated = true; + return { + ...b, + status: evt.status, + ...(evt.status === 'failed' && evt.failureMessage + ? { failureMessage: evt.failureMessage } + : {}), + }; + } + return b; + }); + if (!updated) return m; + return { ...m, blocks }; + }), + }); + return; + } if (evt.type === 'chat_assistant_message_completed') { return; } diff --git a/packages/frontend/src/lib/ws.ts b/packages/frontend/src/lib/ws.ts index 032b208..c5dc556 100644 --- a/packages/frontend/src/lib/ws.ts +++ b/packages/frontend/src/lib/ws.ts @@ -20,7 +20,7 @@ export interface AgentHandle { // WS ベースの agent 呼び出し。受信した NDJSON を AgentEvent の AsyncIterable に変換する。 // close() で接続を明示的に終わらせる。サーバ側が close したら AsyncIterable も終了する。 export function startAgent(opts: StartAgentOptions): AgentHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:4000'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:3322'; const ws = new WebSocket(`${url}/agent`); const buf: AgentEvent[] = []; @@ -107,7 +107,7 @@ export interface OpenChatOptions { // sendUserMessage / approveTool を任意のタイミングで呼ぶ。 // サーバが close した場合は events も終了する。 export function openChat(opts: OpenChatOptions): ChatHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:4000'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:3322'; const ws = new WebSocket(`${url}/chat`); const buf: ChatEvent[] = []; diff --git a/packages/storage/src/chat-store.test.ts b/packages/storage/src/chat-store.test.ts index 744f04b..181fb18 100644 --- a/packages/storage/src/chat-store.test.ts +++ b/packages/storage/src/chat-store.test.ts @@ -64,6 +64,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'pending', }, ], @@ -74,6 +75,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'approved', }); const reloaded = await store.getChat(thread.id); @@ -155,15 +157,18 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); const reloaded = await store.getChat(thread.id); expect(reloaded?.messages[0]?.blocks).toHaveLength(2); + // source は schema の default で 'internal' が入る (Phase 6+ Atlassian MCP 連携の準備) expect(reloaded?.messages[0]?.blocks[1]).toEqual({ type: 'tool_use', toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); } finally { @@ -229,6 +234,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-a', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'A', body: '' }, + source: 'internal', approval: 'pending', }, { @@ -236,6 +242,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-b', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'B', body: '' }, + source: 'internal', approval: 'pending', }, ], diff --git a/packages/storage/src/chat-store.ts b/packages/storage/src/chat-store.ts index 56bf4c5..99cc0c6 100644 --- a/packages/storage/src/chat-store.ts +++ b/packages/storage/src/chat-store.ts @@ -91,15 +91,22 @@ export class FileSystemChatStore implements ChatStore { const yamlFiles = entries.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); const threads = await Promise.all( yamlFiles.map(async (file) => { - const t = await readYaml(path.join(this.paths.chatsDir, file), ChatThreadSchema); - if (!t) return null; - return { - id: t.id, - projectId: t.projectId, - title: t.title, - createdAt: t.createdAt, - updatedAt: t.updatedAt, - } satisfies ChatThreadMeta; + // 1 ファイルが壊れていても他を表示できるように個別 try/catch。 + // 黙って捨てると気付かないので warn は出す。 + try { + const t = await readYaml(path.join(this.paths.chatsDir, file), ChatThreadSchema); + if (!t) return null; + return { + id: t.id, + projectId: t.projectId, + title: t.title, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + } satisfies ChatThreadMeta; + } catch (err) { + console.warn(`[chat-store] skip broken chat file: ${file}`, err); + return null; + } }), ); return threads diff --git a/packages/storage/src/init-project.ts b/packages/storage/src/init-project.ts index 92bed9b..9496ccf 100644 --- a/packages/storage/src/init-project.ts +++ b/packages/storage/src/init-project.ts @@ -71,6 +71,7 @@ export async function initProject(input: InitProjectInput): Promise { name: 'テストプロジェクト', description: '説明', codebases: [], + mcpServers: [], createdAt: '2026-04-18T10:00:00Z', updatedAt: '2026-04-18T10:00:00Z', }); @@ -355,6 +356,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -372,6 +374,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases, + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -386,6 +389,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -407,6 +411,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); diff --git a/packages/storage/src/yaml.test.ts b/packages/storage/src/yaml.test.ts index 8eb483d..7f95384 100644 --- a/packages/storage/src/yaml.test.ts +++ b/packages/storage/src/yaml.test.ts @@ -148,6 +148,44 @@ name: old }); }); + describe('writeYaml flow→block 強制', () => { + // regression: 空配列 (`messages: []`) を初回書き込みすると yaml lib は flow style で + // 出力する。次回書き込み時、既存 seq が flow=true のまま map を追加すると + // 「フロー集約の中にブロック scalar」が混在し再パースが破綻していた (chat YAML 破損)。 + it('id 配列に複数行 string を含む要素を追加しても再パース可能な YAML が出る', async () => { + const filePath = path.join(workspace, 'messages.yaml'); + // Step 1: 空配列で初回書き込み (yaml lib が `messages: []` flow style で書き出す) + await writeYaml(filePath, { + id: 'thread-1', + messages: [], + }); + // Step 2: 複数行 string を含む要素を追加 + await writeYaml(filePath, { + id: 'thread-1', + messages: [ + { + id: 'msg-1', + text: 'line one\n\nline two\n\nline three', + }, + ], + }); + // 再パースできれば fix 成立 + const re = await readYaml( + filePath, + z.object({ + id: z.string(), + messages: z.array(z.object({ id: z.string(), text: z.string() })), + }), + ); + expect(re?.messages).toHaveLength(1); + expect(re?.messages[0]?.text).toBe('line one\n\nline two\n\nline three'); + // 出力は block style (`- id: msg-1`) になっているべき + const raw = await fs.readFile(filePath, 'utf8'); + expect(raw).toContain('- id: msg-1'); + expect(raw).not.toMatch(/messages:\s*\[/); + }); + }); + describe('writeYaml + readYaml 往復', () => { it('書いて読み直せば元のデータと一致する', async () => { const filePath = path.join(workspace, 'roundtrip.yaml'); diff --git a/packages/storage/src/yaml.ts b/packages/storage/src/yaml.ts index 8cd53f3..2f6180b 100644 --- a/packages/storage/src/yaml.ts +++ b/packages/storage/src/yaml.ts @@ -78,6 +78,7 @@ function serializePreservingComments(doc: Document, data: Record & { id: string }> { @@ -124,6 +141,9 @@ function mergeSeqById( data: Array & { id: string }>, doc: Document, ): void { + // 既存 seq が flow style (`[]` で初期化された空配列由来) のままだと、 + // 中に追加する map が flow になり複数行 string と相性が悪い。block 強制。 + seq.flow = false; // 既存アイテムを id で引ける Map にする const existingById = new Map(); for (const item of seq.items) { @@ -140,9 +160,11 @@ function mergeSeqById( const existing = existingById.get(obj.id); if (existing) { updateMapInPlace(existing, obj, doc); + forceBlockStyle(existing); nextItems.push(existing); } else { const newNode = doc.createNode(obj); + forceBlockStyle(newNode); nextItems.push(newNode as YamlNode); } }