import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { createDelegateRunsRouter } from './delegate-runs-api.js'; import type { Repository } from '../db/repository.js'; // --------------------------------------------------------------------------- // Subtask-event helper: write events.jsonl to {dir}/logs/events.jsonl // --------------------------------------------------------------------------- function writeSubEvents(dir: string, lines: object[]) { mkdirSync(join(dir, 'logs'), { recursive: true }); writeFileSync( join(dir, 'logs', 'events.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf-8', ); } // --------------------------------------------------------------------------- // Minimal repo mock — mirrors subtask-activity-api.test.ts pattern // --------------------------------------------------------------------------- function makeRepo(overrides: Partial = {}): Repository { return { getLocalTask: vi.fn(), getLatestJobForIssue: vi.fn(), getSubJobs: vi.fn(), getJob: vi.fn(), userCanViewSpace: vi.fn().mockReturnValue(false), ...overrides, } as unknown as Repository; } // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- /** Write lines to {workspacePath}/logs/events.jsonl (logRoot fallback path). */ function writeEventsJsonl(workspacePath: string, lines: object[]) { mkdirSync(join(workspacePath, 'logs'), { recursive: true }); writeFileSync( join(workspacePath, 'logs', 'events.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf-8', ); } // Canonical minimal event factories const START = (id: string) => ({ v: 1, ts: '2026-06-25T00:00:01.000Z', seq: 1, eventId: 's', runId: 'r', kind: 'delegate_start', payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1 }, }); const TOOL = (id: string) => ({ v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't', runId: 'r', kind: 'tool_call', correlationId: id, payload: { toolName: 'Read', toolCallId: 'tc1' }, }); const DONE = (id: string) => ({ v: 1, ts: '2026-06-25T00:00:03.000Z', seq: 3, eventId: 'c', runId: 'r', kind: 'delegate_complete', payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1, next: 'COMPLETE', status: 'success' }, }); // --------------------------------------------------------------------------- // seedTaskWithWorkspace: build a test Express app + mocked repo + a task that // has a non-null workspacePath backed by a real tmp directory. // Also provides otherUsersTaskId whose getLocalTask mock returns null (viewer // cannot see it), to test the visibility gate. // --------------------------------------------------------------------------- async function seedTaskWithWorkspace() { const workspacePath = mkdtempSync(join(tmpdir(), 'delegate-runs-test-')); const TASK = { id: 1, title: 'test task', workspacePath, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }; const repo = makeRepo(); // By default task 1 is visible (no user set on req, so canViewTask returns true) vi.mocked(repo.getLocalTask).mockImplementation(async (id: number) => { if (id === 1) return TASK as never; return null as never; }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); return { app, repo, taskId: 1, workspacePath, otherUsersTaskId: 99 }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('GET /api/local/tasks/:id/delegate-runs', () => { let tmpDirs: string[] = []; afterEach(() => { for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } tmpDirs = []; vi.clearAllMocks(); }); it('events.jsonl から run 一覧を返す', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`); expect(res.status).toBe(200); expect(res.body.runs).toHaveLength(1); expect(res.body.runs[0]).toMatchObject({ delegateRunId: 'R1', status: 'success', toolCalls: 1 }); }); it('events.jsonl 不在なら空配列(エラーにしない)', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); // No events.jsonl written const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`); expect(res.status).toBe(200); expect(res.body.runs).toEqual([]); }); it('複数 run を delegateRunId で区別して返す', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); writeEventsJsonl(workspacePath, [ START('R1'), TOOL('R1'), DONE('R1'), START('R2'), DONE('R2'), ]); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`); expect(res.status).toBe(200); expect(res.body.runs).toHaveLength(2); expect(res.body.runs.map((r: { delegateRunId: string }) => r.delegateRunId)).toEqual(['R1', 'R2']); }); it('非可視タスクは 404 を返す', async () => { const { app, otherUsersTaskId } = await seedTaskWithWorkspace(); const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs`); expect([403, 404]).toContain(res.status); }); it('authenticated non-owner viewer blocked from private task (IDOR protection)', async () => { const { repo, taskId, workspacePath } = await seedTaskWithWorkspace(); const tmpDirs = [workspacePath]; const mockGetLocalTask = vi.mocked(repo.getLocalTask); // Configure mock: when viewer is passed (authenticated request), // return null (simulating DB-level visibility filter) mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => { if (id === taskId) { if (opts?.viewer) { // Non-owner viewer: visibility filter returns null return null as never; } // No viewer (unauthenticated): allowed to view return { id: taskId, title: 'test task', workspacePath, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, } as never; } return null as never; }); // Create app with middleware that injects non-owner viewer const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User; const app = express(); app.use((req: any, _res: any, next: any) => { req.user = nonOwnerUser; next(); }); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`); // Verify: request is blocked (404 or 403) and no data is leaked expect([403, 404]).toContain(res.status); expect(res.body.runs).toBeUndefined(); expect(res.body.error).toBeDefined(); // Verify: getLocalTask was called WITH the viewer object expect(mockGetLocalTask).toHaveBeenCalledWith( taskId, expect.objectContaining({ viewer: nonOwnerUser }) ); for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } }); }); describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () => { let tmpDirs: string[] = []; afterEach(() => { for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } tmpDirs = []; vi.clearAllMocks(); }); it('drill-down: その run の内部イベントのみ返す', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`); expect(res.status).toBe(200); // Events that have correlationId === 'R1' — TOOL has it; START/DONE do not since they // are keyed by kind not correlationId in the filter expect(Array.isArray(res.body.events)).toBe(true); // The TOOL event has correlationId === 'R1', others do not expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true); }); it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => { // 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は // delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の // タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。 const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); const REAL_TOOL = { v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r', kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1', movement: 'delegate:d', payload: { tool: 'Read' }, }; writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`); expect(res.status).toBe(200); // ツールイベントが(correlation 上書きされていても)返る expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true); }); it('timeline: events.jsonl 不在なら空配列', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`); expect(res.status).toBe(200); expect(res.body.events).toEqual([]); }); it('timeline: 非可視タスクは 404', async () => { const { app, otherUsersTaskId } = await seedTaskWithWorkspace(); const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs/R1/timeline`); expect([403, 404]).toContain(res.status); }); it('timeline: authenticated non-owner viewer blocked from private task (IDOR protection)', async () => { const { repo, taskId, workspacePath } = await seedTaskWithWorkspace(); const tmpDirs = [workspacePath]; const mockGetLocalTask = vi.mocked(repo.getLocalTask); // Configure mock: when viewer is passed (authenticated request), // return null (simulating DB-level visibility filter) mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => { if (id === taskId) { if (opts?.viewer) { // Non-owner viewer: visibility filter returns null return null as never; } // No viewer (unauthenticated): allowed to view return { id: taskId, title: 'test task', workspacePath, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, } as never; } return null as never; }); // Create app with middleware that injects non-owner viewer const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User; const app = express(); app.use((req: any, _res: any, next: any) => { req.user = nonOwnerUser; next(); }); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`); // Verify: request is blocked (404 or 403) and no data is leaked expect([403, 404]).toContain(res.status); expect(res.body.events).toBeUndefined(); expect(res.body.error).toBeDefined(); // Verify: getLocalTask was called WITH the viewer object expect(mockGetLocalTask).toHaveBeenCalledWith( taskId, expect.objectContaining({ viewer: nonOwnerUser }) ); for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } }); }); // --------------------------------------------------------------------------- // Timeline jobId scope tests (Task 3) // --------------------------------------------------------------------------- describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline?jobId=', () => { let tmpDirs: string[] = []; afterEach(() => { for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } tmpDirs = []; vi.clearAllMocks(); }); it('timeline は jobId 指定でサブジョブの events を読む', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); const subDir = join(parentDir, 'subtasks', 'sub1'); tmpDirs.push(parentDir); // Only write events in the sub-job dir (not the parent) writeSubEvents(subDir, [ { v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } }, ]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getJob: vi.fn().mockImplementation(async (id: string) => { if (id === 'sub1') { return { id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' }; } return null; }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=sub1'); expect(res.status).toBe(200); expect(Array.isArray(res.body.events)).toBe(true); expect(res.body.events.length).toBeGreaterThan(0); expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true); }); it('timeline jobId: ジョブが見つからない場合は 404', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); tmpDirs.push(parentDir); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getJob: vi.fn().mockResolvedValue(null), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=nonexistent'); expect(res.status).toBe(404); }); it('timeline jobId: workspacePath 外の worktree は fail-closed で 404', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); const outsideDir = mkdtempSync(join(tmpdir(), 'evil-')); tmpDirs.push(parentDir, outsideDir); writeSubEvents(outsideDir, [ { v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'evilRun', payload: { toolName: 'Read', toolCallId: 'tc1' } }, ]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getJob: vi.fn().mockResolvedValue({ id: 'evil', issueNumber: 1, status: 'succeeded', worktreePath: outsideDir, parentJobId: 'root', }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs/evilRun/timeline?jobId=evil'); expect(res.status).toBe(404); }); it('timeline jobId 未指定: 親 events に correlationId 一致なければサブジョブを線形探索', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); const subDir = join(parentDir, 'subtasks', 'sub1'); tmpDirs.push(parentDir); // Parent has no matching correlationId events; sub-job does writeSubEvents(subDir, [ { v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } }, ]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }), getSubJobs: vi.fn().mockImplementation(async (pid: string) => { if (pid === 'root') { return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' }]; } return []; }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline'); expect(res.status).toBe(200); expect(res.body.events.length).toBeGreaterThan(0); expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true); }); }); // --------------------------------------------------------------------------- // Subtask aggregation tests (Task 2) // --------------------------------------------------------------------------- describe('GET /api/local/tasks/:id/delegate-runs — subtask aggregation', () => { let tmpDirs: string[] = []; afterEach(() => { for (const dir of tmpDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } tmpDirs = []; vi.clearAllMocks(); }); // Event factories reusing the same shape as START/DONE above const mkStart = (id: string) => ({ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: `s-${id}`, runId: 'r', kind: 'delegate_start', payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1 }, }); const mkDone = (id: string) => ({ v: 1, ts: '2026-06-29T00:00:01.000Z', seq: 2, eventId: `c-${id}`, runId: 'r', kind: 'delegate_complete', payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' }, }); it('親 run とサブタスク run を分けて返す', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); const subDir = join(parentDir, 'subtasks', 'sub1'); tmpDirs.push(parentDir); // Write parent events writeSubEvents(parentDir, [mkStart('parentRun'), mkDone('parentRun')]); // Write sub-job events writeSubEvents(subDir, [mkStart('subRun'), mkDone('subRun')]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }), getSubJobs: vi.fn().mockImplementation(async (pid: string) => { if (pid === 'root') { return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root', }]; } return []; }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs'); expect(res.status).toBe(200); // Parent runs expect(res.body.runs).toHaveLength(1); expect(res.body.runs[0].delegateRunId).toBe('parentRun'); // Subtask runs expect(res.body.subtasks).toHaveLength(1); expect(res.body.subtasks[0].jobId).toBe('sub1'); expect(res.body.subtasks[0].depth).toBe(1); expect(res.body.subtasks[0].status).toBe('succeeded'); expect(res.body.subtasks[0].runs).toHaveLength(1); expect(res.body.subtasks[0].runs[0].delegateRunId).toBe('subRun'); }); it('親ワークスペース外の worktree を持つサブジョブは除外 (auth containment)', async () => { const parentDir = mkdtempSync(join(tmpdir(), 'parent-')); const outsideDir = mkdtempSync(join(tmpdir(), 'evil-')); tmpDirs.push(parentDir, outsideDir); // Write parent events (no delegate runs → empty) // Write events to the outside dir to prove it's excluded, not missing writeSubEvents(outsideDir, [mkStart('evilRun'), mkDone('evilRun')]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: parentDir, runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }), getSubJobs: vi.fn().mockImplementation(async (pid: string) => { if (pid === 'root') { return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: outsideDir, parentJobId: 'root', }]; } return []; }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs'); expect(res.status).toBe(200); // Outside sub-job must be excluded expect(res.body.subtasks).toHaveLength(0); }); it('workspacePath が null のタスクはサブジョブ containment を評価できないため subtasks が空 (fail-closed regression)', async () => { // Regression: old code used `task.workspacePath && !isJobWithinWorkspace(...)` — when // workspacePath is null the && short-circuits and the check is BYPASSED, leaking events. // Fixed to: `!isJobWithinWorkspace(null, path)` which returns false → skips the sub-job. const subDir = mkdtempSync(join(tmpdir(), 'sub-')); tmpDirs.push(subDir); // Write delegate events into the sub-job dir so it would appear if included writeSubEvents(subDir, [mkStart('leakedRun'), mkDone('leakedRun')]); const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue({ id: 1, workspacePath: null, // ← null triggers the bypass in old code runtimeDir: null, ownerId: 'alice-id', visibility: 'private' as const, visibilityScopeOrgId: null, spaceId: null, }), getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }), getSubJobs: vi.fn().mockImplementation(async (pid: string) => { if (pid === 'root') { return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root', }]; } return []; }), }); const app = express(); app.use(express.json()); app.use('/api/local/tasks', createDelegateRunsRouter(repo)); const res = await request(app).get('/api/local/tasks/1/delegate-runs'); expect(res.status).toBe(200); // Sub-job must be skipped because workspacePath is null (containment cannot be evaluated) expect(res.body.subtasks).toHaveLength(0); }); it('既存テスト互換: サブジョブ無し時は subtasks が空配列', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); writeSubEvents(workspacePath, [mkStart('R1'), mkDone('R1')]); const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`); expect(res.status).toBe(200); expect(res.body.runs).toHaveLength(1); // getLatestJobForIssue is vi.fn() returning undefined → latestJob falsy → empty subtasks expect(res.body.subtasks).toEqual([]); }); });