@kmgeon/taskflow 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +374 -0
- package/bin/task-mcp.mjs +19 -0
- package/bin/task.mjs +19 -0
- package/docs/clean-code.md +29 -0
- package/docs/git.md +36 -0
- package/docs/guideline.md +25 -0
- package/docs/security.md +32 -0
- package/docs/step-by-step.md +29 -0
- package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
- package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
- package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
- package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
- package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
- package/docs/tdd.md +41 -0
- package/package.json +114 -0
- package/src/app/(protected)/dashboard/page.tsx +7 -0
- package/src/app/(protected)/layout.tsx +10 -0
- package/src/app/api/[[...hono]]/route.ts +13 -0
- package/src/app/example/page.tsx +11 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +168 -0
- package/src/app/layout.tsx +35 -0
- package/src/app/page.tsx +5 -0
- package/src/app/providers.tsx +57 -0
- package/src/backend/config/index.ts +36 -0
- package/src/backend/hono/app.ts +32 -0
- package/src/backend/hono/context.ts +38 -0
- package/src/backend/http/response.ts +64 -0
- package/src/backend/middleware/context.ts +23 -0
- package/src/backend/middleware/error.ts +31 -0
- package/src/backend/middleware/supabase.ts +23 -0
- package/src/backend/supabase/client.ts +17 -0
- package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
- package/src/cli/commands/advisor.ts +45 -0
- package/src/cli/commands/ask.ts +50 -0
- package/src/cli/commands/board.ts +72 -0
- package/src/cli/commands/init.ts +184 -0
- package/src/cli/commands/list.ts +138 -0
- package/src/cli/commands/run.ts +143 -0
- package/src/cli/commands/set-status.ts +50 -0
- package/src/cli/commands/show.ts +28 -0
- package/src/cli/commands/tree.ts +72 -0
- package/src/cli/index.ts +38 -0
- package/src/cli/lib/__tests__/formatter.test.ts +123 -0
- package/src/cli/lib/error-boundary.test.ts +135 -0
- package/src/cli/lib/error-boundary.ts +70 -0
- package/src/cli/lib/formatter.ts +764 -0
- package/src/cli/lib/trd.ts +33 -0
- package/src/cli/lib/validate.test.ts +89 -0
- package/src/cli/lib/validate.ts +43 -0
- package/src/cli/prompts/task-run.md +25 -0
- package/src/components/layout/AppLayout.tsx +15 -0
- package/src/components/layout/Sidebar.tsx +124 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/file-upload.tsx +50 -0
- package/src/components/ui/form.tsx +179 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toast.tsx +129 -0
- package/src/components/ui/toaster.tsx +35 -0
- package/src/core/ai/claude-client.ts +79 -0
- package/src/core/claude-runner/flag-builder.ts +57 -0
- package/src/core/claude-runner/index.ts +2 -0
- package/src/core/claude-runner/spawner.ts +86 -0
- package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
- package/src/core/prd/__tests__/generator.test.ts +26 -0
- package/src/core/prd/__tests__/scanner.test.ts +35 -0
- package/src/core/prd/auto-analyzer.ts +9 -0
- package/src/core/prd/generator.ts +8 -0
- package/src/core/prd/scanner.ts +117 -0
- package/src/core/project/__tests__/claude-setup.test.ts +133 -0
- package/src/core/project/__tests__/config.test.ts +30 -0
- package/src/core/project/__tests__/init.test.ts +37 -0
- package/src/core/project/__tests__/skill-setup.test.ts +62 -0
- package/src/core/project/claude-setup.ts +224 -0
- package/src/core/project/config.ts +34 -0
- package/src/core/project/docs-setup.ts +26 -0
- package/src/core/project/docs-templates.ts +205 -0
- package/src/core/project/init.ts +40 -0
- package/src/core/project/skill-setup.ts +32 -0
- package/src/core/project/skill-templates.ts +277 -0
- package/src/core/task/index.ts +16 -0
- package/src/core/types.ts +58 -0
- package/src/features/example/backend/error.ts +9 -0
- package/src/features/example/backend/route.ts +52 -0
- package/src/features/example/backend/schema.ts +25 -0
- package/src/features/example/backend/service.ts +73 -0
- package/src/features/example/components/example-status.test.tsx +97 -0
- package/src/features/example/components/example-status.tsx +160 -0
- package/src/features/example/hooks/useExampleQuery.ts +23 -0
- package/src/features/example/lib/dto.test.ts +57 -0
- package/src/features/example/lib/dto.ts +5 -0
- package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
- package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
- package/src/features/kanban/backend/route.ts +55 -0
- package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
- package/src/features/kanban/backend/sse-route.ts +43 -0
- package/src/features/kanban/components/KanbanBoard.tsx +105 -0
- package/src/features/kanban/components/KanbanColumn.tsx +51 -0
- package/src/features/kanban/components/KanbanError.tsx +29 -0
- package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
- package/src/features/kanban/components/ProgressCard.tsx +42 -0
- package/src/features/kanban/components/TaskCard.tsx +76 -0
- package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
- package/src/features/kanban/hooks/useTaskSse.ts +66 -0
- package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
- package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
- package/src/features/kanban/lib/kanban-utils.ts +37 -0
- package/src/features/taskflow/constants.ts +54 -0
- package/src/features/taskflow/index.ts +27 -0
- package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
- package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
- package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
- package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
- package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
- package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
- package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
- package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
- package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
- package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
- package/src/features/taskflow/lib/advisor/db.ts +185 -0
- package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
- package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
- package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
- package/src/features/taskflow/lib/filter.ts +54 -0
- package/src/features/taskflow/lib/fs-utils.ts +50 -0
- package/src/features/taskflow/lib/graph.ts +148 -0
- package/src/features/taskflow/lib/index-builder.ts +42 -0
- package/src/features/taskflow/lib/repository.ts +168 -0
- package/src/features/taskflow/lib/serializer.ts +62 -0
- package/src/features/taskflow/lib/watcher.ts +40 -0
- package/src/features/taskflow/types.ts +71 -0
- package/src/hooks/use-toast.ts +194 -0
- package/src/lib/remote/api-client.ts +40 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/server.ts +46 -0
- package/src/lib/supabase/types.ts +3 -0
- package/src/lib/utils.ts +6 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/server.ts +21 -0
- package/src/mcp/tools/brainstorm.ts +48 -0
- package/src/mcp/tools/prd.ts +71 -0
- package/src/mcp/tools/project.ts +39 -0
- package/src/mcp/tools/task-status.ts +40 -0
- package/src/mcp/tools/task.ts +82 -0
- package/src/mcp/util.ts +6 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { FormEvent } from 'react';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { useExampleQuery } from '@/features/example/hooks/useExampleQuery';
|
|
8
|
+
|
|
9
|
+
const statusBadge = (
|
|
10
|
+
label: string,
|
|
11
|
+
tone: 'success' | 'error' | 'idle',
|
|
12
|
+
) => {
|
|
13
|
+
const toneStyles: Record<typeof tone, string> = {
|
|
14
|
+
success: 'bg-emerald-500/10 text-emerald-300 border-emerald-400/40',
|
|
15
|
+
error: 'bg-rose-500/10 text-rose-300 border-rose-400/40',
|
|
16
|
+
idle: 'bg-slate-500/10 text-slate-200 border-slate-400/30',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${toneStyles[tone]}`}
|
|
22
|
+
>
|
|
23
|
+
{label}
|
|
24
|
+
</span>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ExampleStatus = () => {
|
|
29
|
+
const [inputValue, setInputValue] = useState('');
|
|
30
|
+
const [exampleId, setExampleId] = useState('');
|
|
31
|
+
const query = useExampleQuery(exampleId);
|
|
32
|
+
|
|
33
|
+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
const trimmed = inputValue.trim();
|
|
36
|
+
|
|
37
|
+
if (!trimmed) {
|
|
38
|
+
setExampleId('');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (trimmed === exampleId) {
|
|
43
|
+
void query.refetch();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setExampleId(trimmed);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<section className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
|
52
|
+
<header className="space-y-2 text-slate-100">
|
|
53
|
+
<h1 className="text-3xl font-semibold tracking-tight">Backend Health Check</h1>
|
|
54
|
+
<p className="text-sm text-slate-300">
|
|
55
|
+
예시 API(`/api/example/:id`)가 정상 동작하는지 확인합니다. Supabase 예시
|
|
56
|
+
레코드의 UUID를 입력하면 React Query를 통해 백엔드 응답을 확인할 수
|
|
57
|
+
있습니다.
|
|
58
|
+
</p>
|
|
59
|
+
</header>
|
|
60
|
+
|
|
61
|
+
<form
|
|
62
|
+
onSubmit={handleSubmit}
|
|
63
|
+
className="flex flex-col gap-3 rounded-xl border border-slate-800 bg-slate-950/60 p-4 md:flex-row md:items-center"
|
|
64
|
+
>
|
|
65
|
+
<div className="flex-1 space-y-1">
|
|
66
|
+
<label className="text-xs uppercase tracking-wide text-slate-400">
|
|
67
|
+
Example UUID
|
|
68
|
+
</label>
|
|
69
|
+
<Input
|
|
70
|
+
value={inputValue}
|
|
71
|
+
onChange={(event) => setInputValue(event.target.value)}
|
|
72
|
+
placeholder="00000000-0000-0000-0000-000000000000"
|
|
73
|
+
className="bg-slate-900/70 text-slate-100 placeholder:text-slate-600"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<Button
|
|
77
|
+
type="submit"
|
|
78
|
+
variant="secondary"
|
|
79
|
+
className="mt-2 h-12 rounded-lg border border-slate-600 bg-slate-800 text-slate-100 hover:bg-slate-700 md:mt-6"
|
|
80
|
+
>
|
|
81
|
+
조회하기
|
|
82
|
+
</Button>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<article className="space-y-3 rounded-xl border border-slate-800 bg-slate-950/60 p-6 text-slate-100">
|
|
86
|
+
<div className="flex items-center justify-between">
|
|
87
|
+
<h2 className="text-lg font-semibold">현재 상태</h2>
|
|
88
|
+
{exampleId
|
|
89
|
+
? query.status === 'pending'
|
|
90
|
+
? statusBadge('Fetching', 'idle')
|
|
91
|
+
: query.status === 'error'
|
|
92
|
+
? statusBadge('Error', 'error')
|
|
93
|
+
: statusBadge('Success', 'success')
|
|
94
|
+
: statusBadge('Idle', 'idle')}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{!exampleId && (
|
|
98
|
+
<p className="text-sm text-slate-300">
|
|
99
|
+
UUID를 입력하고 조회하기 버튼을 누르면 결과가 이곳에 표시됩니다.
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{exampleId && query.status === 'pending' && (
|
|
104
|
+
<p className="text-sm text-slate-300">Supabase에서 데이터를 가져오는 중...</p>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{query.status === 'error' && (
|
|
108
|
+
<div className="space-y-2 rounded-lg border border-rose-400/30 bg-rose-500/5 p-4">
|
|
109
|
+
<p className="text-sm font-medium text-rose-300">요청 실패</p>
|
|
110
|
+
<p className="text-xs text-rose-200/80">
|
|
111
|
+
{query.error instanceof Error
|
|
112
|
+
? query.error.message
|
|
113
|
+
: '알 수 없는 에러가 발생했습니다.'}
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{query.data && (
|
|
119
|
+
<div className="space-y-3 rounded-lg border border-emerald-400/30 bg-emerald-500/5 p-4 text-sm text-emerald-100">
|
|
120
|
+
<div>
|
|
121
|
+
<p className="text-xs uppercase tracking-wide text-emerald-300">ID</p>
|
|
122
|
+
<p className="font-mono text-xs md:text-sm">{query.data.id}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<div>
|
|
125
|
+
<p className="text-xs uppercase tracking-wide text-emerald-300">
|
|
126
|
+
이름
|
|
127
|
+
</p>
|
|
128
|
+
<p>{query.data.fullName}</p>
|
|
129
|
+
</div>
|
|
130
|
+
<div>
|
|
131
|
+
<p className="text-xs uppercase tracking-wide text-emerald-300">
|
|
132
|
+
소개
|
|
133
|
+
</p>
|
|
134
|
+
<p>{query.data.bio ?? '—'}</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<p className="text-xs uppercase tracking-wide text-emerald-300">
|
|
138
|
+
아바타
|
|
139
|
+
</p>
|
|
140
|
+
<a
|
|
141
|
+
href={query.data.avatarUrl}
|
|
142
|
+
target="_blank"
|
|
143
|
+
rel="noreferrer"
|
|
144
|
+
className="underline"
|
|
145
|
+
>
|
|
146
|
+
{query.data.avatarUrl}
|
|
147
|
+
</a>
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<p className="text-xs uppercase tracking-wide text-emerald-300">
|
|
151
|
+
업데이트 시각
|
|
152
|
+
</p>
|
|
153
|
+
<p>{query.data.updatedAt}</p>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</article>
|
|
158
|
+
</section>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { apiClient, extractApiErrorMessage } from '@/lib/remote/api-client';
|
|
5
|
+
import { ExampleResponseSchema } from '@/features/example/lib/dto';
|
|
6
|
+
|
|
7
|
+
const fetchExample = async (id: string) => {
|
|
8
|
+
try {
|
|
9
|
+
const { data } = await apiClient.get(`/api/example/${id}`);
|
|
10
|
+
return ExampleResponseSchema.parse(data);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
const message = extractApiErrorMessage(error, 'Failed to fetch example.');
|
|
13
|
+
throw new Error(message);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const useExampleQuery = (id: string) =>
|
|
18
|
+
useQuery({
|
|
19
|
+
queryKey: ['example', id],
|
|
20
|
+
queryFn: () => fetchExample(id),
|
|
21
|
+
enabled: Boolean(id),
|
|
22
|
+
staleTime: 60 * 1000,
|
|
23
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ExampleResponseSchema } from './dto';
|
|
3
|
+
|
|
4
|
+
describe('ExampleResponseSchema', () => {
|
|
5
|
+
it('should validate a correct example response', () => {
|
|
6
|
+
const validData = {
|
|
7
|
+
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
8
|
+
fullName: 'Test User',
|
|
9
|
+
avatarUrl: 'https://example.com/avatar.jpg',
|
|
10
|
+
bio: 'This is a test bio',
|
|
11
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const result = ExampleResponseSchema.safeParse(validData);
|
|
15
|
+
|
|
16
|
+
expect(result.success).toBe(true);
|
|
17
|
+
if (result.success) {
|
|
18
|
+
expect(result.data).toEqual(validData);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should allow null bio', () => {
|
|
23
|
+
const validData = {
|
|
24
|
+
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
25
|
+
fullName: 'Test User',
|
|
26
|
+
avatarUrl: 'https://example.com/avatar.jpg',
|
|
27
|
+
bio: null,
|
|
28
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = ExampleResponseSchema.safeParse(validData);
|
|
32
|
+
|
|
33
|
+
expect(result.success).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should reject invalid data structure', () => {
|
|
37
|
+
const invalidData = {
|
|
38
|
+
id: 123, // should be string
|
|
39
|
+
fullName: 'Test User',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const result = ExampleResponseSchema.safeParse(invalidData);
|
|
43
|
+
|
|
44
|
+
expect(result.success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should reject missing required fields', () => {
|
|
48
|
+
const invalidData = {
|
|
49
|
+
id: 'test-id',
|
|
50
|
+
// missing fullName, avatarUrl, updatedAt
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = ExampleResponseSchema.safeParse(invalidData);
|
|
54
|
+
|
|
55
|
+
expect(result.success).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getSseBroadcaster, resetBroadcaster, type SseEvent } from "../sse-broadcaster";
|
|
6
|
+
import { ensureRepo, createTask, updateTask, deleteTask } from "@/features/taskflow/lib/repository";
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
resetBroadcaster();
|
|
12
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sse-test-"));
|
|
13
|
+
await ensureRepo(tmpDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
const broadcaster = getSseBroadcaster();
|
|
18
|
+
await broadcaster.destroy();
|
|
19
|
+
resetBroadcaster();
|
|
20
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("SseBroadcaster", () => {
|
|
24
|
+
it("should be a singleton", () => {
|
|
25
|
+
const a = getSseBroadcaster();
|
|
26
|
+
const b = getSseBroadcaster();
|
|
27
|
+
expect(a).toBe(b);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should reset properly", () => {
|
|
31
|
+
const a = getSseBroadcaster();
|
|
32
|
+
resetBroadcaster();
|
|
33
|
+
const b = getSseBroadcaster();
|
|
34
|
+
expect(a).not.toBe(b);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should subscribe and unsubscribe listeners", () => {
|
|
38
|
+
const broadcaster = getSseBroadcaster();
|
|
39
|
+
const listener = vi.fn();
|
|
40
|
+
|
|
41
|
+
const unsub = broadcaster.subscribe(listener);
|
|
42
|
+
expect(broadcaster.subscriberCount).toBe(1);
|
|
43
|
+
|
|
44
|
+
unsub();
|
|
45
|
+
expect(broadcaster.subscriberCount).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should support multiple subscribers", () => {
|
|
49
|
+
const broadcaster = getSseBroadcaster();
|
|
50
|
+
const l1 = vi.fn();
|
|
51
|
+
const l2 = vi.fn();
|
|
52
|
+
const l3 = vi.fn();
|
|
53
|
+
|
|
54
|
+
const u1 = broadcaster.subscribe(l1);
|
|
55
|
+
const u2 = broadcaster.subscribe(l2);
|
|
56
|
+
const u3 = broadcaster.subscribe(l3);
|
|
57
|
+
|
|
58
|
+
expect(broadcaster.subscriberCount).toBe(3);
|
|
59
|
+
|
|
60
|
+
u2();
|
|
61
|
+
expect(broadcaster.subscriberCount).toBe(2);
|
|
62
|
+
|
|
63
|
+
u1();
|
|
64
|
+
u3();
|
|
65
|
+
expect(broadcaster.subscriberCount).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should broadcast file change events to subscribers", async () => {
|
|
69
|
+
const broadcaster = getSseBroadcaster();
|
|
70
|
+
broadcaster.init(tmpDir);
|
|
71
|
+
|
|
72
|
+
const events: SseEvent[] = [];
|
|
73
|
+
broadcaster.subscribe((e) => events.push(e));
|
|
74
|
+
|
|
75
|
+
// Create a task file — triggers chokidar "add"
|
|
76
|
+
await createTask(tmpDir, { title: "SSE test task", priority: 5 });
|
|
77
|
+
|
|
78
|
+
// Wait for chokidar + throttle (awaitWriteFinish 100ms + throttle 300ms + margin)
|
|
79
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
80
|
+
|
|
81
|
+
// Should have received at least one event
|
|
82
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
83
|
+
const taskEvent = events.find((e) => e.type === "created" || e.type === "updated");
|
|
84
|
+
// created or updated depending on timing
|
|
85
|
+
expect(taskEvent || events.find((e) => e.type === "index")).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should throttle rapid events", async () => {
|
|
89
|
+
const broadcaster = getSseBroadcaster();
|
|
90
|
+
broadcaster.init(tmpDir);
|
|
91
|
+
|
|
92
|
+
const events: SseEvent[] = [];
|
|
93
|
+
broadcaster.subscribe((e) => {
|
|
94
|
+
if (e.path) events.push(e);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Rapid create 5 tasks
|
|
98
|
+
for (let i = 0; i < 5; i++) {
|
|
99
|
+
await createTask(tmpDir, { title: `Rapid ${i}` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Wait for chokidar awaitWriteFinish + throttle to settle
|
|
103
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
104
|
+
|
|
105
|
+
// Throttling should produce fewer events than raw file operations
|
|
106
|
+
// (5 creates × 2 files each = 10 raw events, throttle collapses them)
|
|
107
|
+
expect(events.length).toBeGreaterThan(0);
|
|
108
|
+
expect(events.length).toBeLessThan(15);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should remove broken listeners gracefully", () => {
|
|
112
|
+
const broadcaster = getSseBroadcaster();
|
|
113
|
+
broadcaster.init(tmpDir);
|
|
114
|
+
|
|
115
|
+
const brokenListener = () => {
|
|
116
|
+
throw new Error("broken");
|
|
117
|
+
};
|
|
118
|
+
const goodListener = vi.fn();
|
|
119
|
+
|
|
120
|
+
broadcaster.subscribe(brokenListener);
|
|
121
|
+
broadcaster.subscribe(goodListener);
|
|
122
|
+
|
|
123
|
+
expect(broadcaster.subscriberCount).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should clean up on destroy", async () => {
|
|
127
|
+
const broadcaster = getSseBroadcaster();
|
|
128
|
+
broadcaster.init(tmpDir);
|
|
129
|
+
|
|
130
|
+
const listener = vi.fn();
|
|
131
|
+
broadcaster.subscribe(listener);
|
|
132
|
+
|
|
133
|
+
await broadcaster.destroy();
|
|
134
|
+
|
|
135
|
+
expect(broadcaster.subscriberCount).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { SseEvent, SseEventType } from "../sse-broadcaster";
|
|
3
|
+
|
|
4
|
+
describe("SseEvent format", () => {
|
|
5
|
+
it("should have required fields for task events", () => {
|
|
6
|
+
const event: SseEvent = {
|
|
7
|
+
type: "created",
|
|
8
|
+
id: "001",
|
|
9
|
+
path: ".taskflow/tasks/task-001.md",
|
|
10
|
+
ts: Date.now(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
expect(event.type).toBe("created");
|
|
14
|
+
expect(event.id).toBe("001");
|
|
15
|
+
expect(event.path).toContain("task-001.md");
|
|
16
|
+
expect(typeof event.ts).toBe("number");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should allow optional id for index events", () => {
|
|
20
|
+
const event: SseEvent = {
|
|
21
|
+
type: "index",
|
|
22
|
+
path: ".taskflow/index/TASKS.md",
|
|
23
|
+
ts: Date.now(),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
expect(event.type).toBe("index");
|
|
27
|
+
expect(event.id).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should serialize to valid JSON", () => {
|
|
31
|
+
const event: SseEvent = {
|
|
32
|
+
type: "updated",
|
|
33
|
+
id: "002",
|
|
34
|
+
path: ".taskflow/tasks/task-002.md",
|
|
35
|
+
ts: 1710979200000,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const json = JSON.stringify(event);
|
|
39
|
+
const parsed = JSON.parse(json);
|
|
40
|
+
|
|
41
|
+
expect(parsed.type).toBe("updated");
|
|
42
|
+
expect(parsed.id).toBe("002");
|
|
43
|
+
expect(parsed.ts).toBe(1710979200000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should cover all event types", () => {
|
|
47
|
+
const types: SseEventType[] = ["created", "updated", "deleted", "index"];
|
|
48
|
+
expect(types).toHaveLength(4);
|
|
49
|
+
|
|
50
|
+
for (const type of types) {
|
|
51
|
+
const event: SseEvent = { type, path: "test", ts: Date.now() };
|
|
52
|
+
expect(event.type).toBe(type);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
listTasks,
|
|
5
|
+
readTask,
|
|
6
|
+
updateTask,
|
|
7
|
+
ensureRepo,
|
|
8
|
+
} from "@/features/taskflow/lib/repository";
|
|
9
|
+
import { TASK_STATUSES } from "@/features/taskflow/types";
|
|
10
|
+
|
|
11
|
+
const statusBodySchema = z.object({
|
|
12
|
+
to: z.enum(TASK_STATUSES),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const registerTaskRoutes = (app: Hono<any>) => {
|
|
16
|
+
const projectRoot = process.cwd();
|
|
17
|
+
|
|
18
|
+
app.get("/api/tasks", async (c) => {
|
|
19
|
+
await ensureRepo(projectRoot);
|
|
20
|
+
const tasks = await listTasks(projectRoot);
|
|
21
|
+
return c.json(tasks);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.get("/api/tasks/:id", async (c) => {
|
|
25
|
+
const id = c.req.param("id");
|
|
26
|
+
const task = await readTask(projectRoot, id);
|
|
27
|
+
|
|
28
|
+
if (!task) {
|
|
29
|
+
return c.json({ error: { code: "NOT_FOUND", message: `Task ${id} not found` } }, 404);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return c.json(task);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.post("/api/tasks/:id/status", async (c) => {
|
|
36
|
+
const id = c.req.param("id");
|
|
37
|
+
const body = await c.req.json();
|
|
38
|
+
const parsed = statusBodySchema.safeParse(body);
|
|
39
|
+
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
return c.json(
|
|
42
|
+
{ error: { code: "INVALID_BODY", message: "Invalid status", details: parsed.error.format() } },
|
|
43
|
+
400,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const updated = await updateTask(projectRoot, id, { status: parsed.data.to });
|
|
49
|
+
return c.json(updated);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
52
|
+
return c.json({ error: { code: "UPDATE_FAILED", message } }, 404);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { watch, type FSWatcher } from "chokidar";
|
|
3
|
+
import { getTasksDir, getIndexFilePath, extractTaskId } from "@/features/taskflow/constants";
|
|
4
|
+
|
|
5
|
+
// ── SSE Event Types ──
|
|
6
|
+
|
|
7
|
+
export type SseEventType = "created" | "updated" | "deleted" | "index";
|
|
8
|
+
|
|
9
|
+
export interface SseEvent {
|
|
10
|
+
type: SseEventType;
|
|
11
|
+
id?: string;
|
|
12
|
+
path: string;
|
|
13
|
+
ts: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SseListener = (event: SseEvent) => void;
|
|
17
|
+
|
|
18
|
+
// ── Broadcaster ──
|
|
19
|
+
|
|
20
|
+
const THROTTLE_MS = 300;
|
|
21
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
class SseBroadcaster {
|
|
24
|
+
private listeners = new Set<SseListener>();
|
|
25
|
+
private watcher: FSWatcher | null = null;
|
|
26
|
+
private lastEmit = 0;
|
|
27
|
+
private pendingEvent: SseEvent | null = null;
|
|
28
|
+
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
private initialized = false;
|
|
31
|
+
|
|
32
|
+
init(projectRoot: string): void {
|
|
33
|
+
if (this.initialized) return;
|
|
34
|
+
this.initialized = true;
|
|
35
|
+
|
|
36
|
+
const tasksDir = getTasksDir(projectRoot);
|
|
37
|
+
const indexPath = getIndexFilePath(projectRoot);
|
|
38
|
+
|
|
39
|
+
this.watcher = watch(
|
|
40
|
+
[path.join(tasksDir, "task-*.md"), indexPath],
|
|
41
|
+
{
|
|
42
|
+
ignoreInitial: true,
|
|
43
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
this.watcher.on("add", (fp) => this.handleFsEvent("created", fp, indexPath));
|
|
48
|
+
this.watcher.on("change", (fp) => this.handleFsEvent("updated", fp, indexPath));
|
|
49
|
+
this.watcher.on("unlink", (fp) => this.handleFsEvent("deleted", fp, indexPath));
|
|
50
|
+
|
|
51
|
+
this.keepAliveTimer = setInterval(() => {
|
|
52
|
+
this.broadcast({ type: "index", path: "", ts: Date.now() });
|
|
53
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
subscribe(listener: SseListener): () => void {
|
|
57
|
+
this.listeners.add(listener);
|
|
58
|
+
return () => {
|
|
59
|
+
this.listeners.delete(listener);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get subscriberCount(): number {
|
|
64
|
+
return this.listeners.size;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async destroy(): Promise<void> {
|
|
68
|
+
if (this.throttleTimer) clearTimeout(this.throttleTimer);
|
|
69
|
+
if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
|
|
70
|
+
if (this.watcher) await this.watcher.close();
|
|
71
|
+
this.listeners.clear();
|
|
72
|
+
this.initialized = false;
|
|
73
|
+
this.watcher = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private handleFsEvent(fsEvent: "created" | "updated" | "deleted", filePath: string, indexPath: string): void {
|
|
77
|
+
const isIndex = path.resolve(filePath) === path.resolve(indexPath);
|
|
78
|
+
|
|
79
|
+
const event: SseEvent = {
|
|
80
|
+
type: isIndex ? "index" : fsEvent,
|
|
81
|
+
id: isIndex ? undefined : (extractTaskId(path.basename(filePath)) ?? undefined),
|
|
82
|
+
path: filePath,
|
|
83
|
+
ts: Date.now(),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
this.throttledBroadcast(event);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private throttledBroadcast(event: SseEvent): void {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const elapsed = now - this.lastEmit;
|
|
92
|
+
|
|
93
|
+
if (elapsed >= THROTTLE_MS) {
|
|
94
|
+
this.lastEmit = now;
|
|
95
|
+
this.broadcast(event);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Throttle: replace pending event (latest wins)
|
|
100
|
+
this.pendingEvent = event;
|
|
101
|
+
|
|
102
|
+
if (!this.throttleTimer) {
|
|
103
|
+
this.throttleTimer = setTimeout(() => {
|
|
104
|
+
this.throttleTimer = null;
|
|
105
|
+
if (this.pendingEvent) {
|
|
106
|
+
this.lastEmit = Date.now();
|
|
107
|
+
this.broadcast(this.pendingEvent);
|
|
108
|
+
this.pendingEvent = null;
|
|
109
|
+
}
|
|
110
|
+
}, THROTTLE_MS - elapsed);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private broadcast(event: SseEvent): void {
|
|
115
|
+
for (const listener of this.listeners) {
|
|
116
|
+
try {
|
|
117
|
+
listener(event);
|
|
118
|
+
} catch {
|
|
119
|
+
// Remove broken listeners
|
|
120
|
+
this.listeners.delete(listener);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Singleton ──
|
|
127
|
+
|
|
128
|
+
let instance: SseBroadcaster | null = null;
|
|
129
|
+
|
|
130
|
+
export function getSseBroadcaster(): SseBroadcaster {
|
|
131
|
+
if (!instance) {
|
|
132
|
+
instance = new SseBroadcaster();
|
|
133
|
+
}
|
|
134
|
+
return instance;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function resetBroadcaster(): void {
|
|
138
|
+
if (instance) {
|
|
139
|
+
instance.destroy();
|
|
140
|
+
instance = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { getSseBroadcaster, type SseEvent } from "./sse-broadcaster";
|
|
4
|
+
|
|
5
|
+
export const registerSseRoute = (app: Hono<any>) => {
|
|
6
|
+
const projectRoot = process.cwd();
|
|
7
|
+
|
|
8
|
+
app.get("/api/sse", (c) => {
|
|
9
|
+
const broadcaster = getSseBroadcaster();
|
|
10
|
+
broadcaster.init(projectRoot);
|
|
11
|
+
|
|
12
|
+
return streamSSE(c, async (stream) => {
|
|
13
|
+
// Send initial connection event
|
|
14
|
+
await stream.writeSSE({
|
|
15
|
+
event: "connected",
|
|
16
|
+
data: JSON.stringify({ ts: Date.now(), subscribers: broadcaster.subscriberCount + 1 }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let closed = false;
|
|
20
|
+
|
|
21
|
+
const unsubscribe = broadcaster.subscribe((event: SseEvent) => {
|
|
22
|
+
if (closed) return;
|
|
23
|
+
stream.writeSSE({
|
|
24
|
+
event: "task-change",
|
|
25
|
+
data: JSON.stringify(event),
|
|
26
|
+
}).catch(() => {
|
|
27
|
+
closed = true;
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Keep stream alive until client disconnects
|
|
32
|
+
stream.onAbort(() => {
|
|
33
|
+
closed = true;
|
|
34
|
+
unsubscribe();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Block until aborted
|
|
38
|
+
await new Promise<void>((resolve) => {
|
|
39
|
+
stream.onAbort(resolve);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
};
|