idea-manager 0.5.0 → 0.6.1
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 +2 -0
- package/package.json +1 -1
- package/src/app/projects/[id]/page.tsx +30 -3
- package/src/cli.ts +17 -0
- package/src/components/task/ProjectTree.tsx +14 -1
- package/src/components/task/TaskChat.tsx +27 -3
- package/src/components/task/TaskDetail.tsx +7 -1
- package/src/components/task/TaskList.tsx +14 -1
- package/src/lib/ai/client.ts +22 -2
- package/src/lib/db/queries/projects.ts +22 -4
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/watcher.ts +236 -0
- package/src/types/index.ts +1 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ interface IProject {
|
|
|
16
16
|
description: string;
|
|
17
17
|
project_path: string | null;
|
|
18
18
|
ai_context: string;
|
|
19
|
+
watch_enabled: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function WorkspaceInner({ id }: { id: string }) {
|
|
@@ -189,9 +190,10 @@ function WorkspaceInner({ id }: { id: string }) {
|
|
|
189
190
|
}
|
|
190
191
|
};
|
|
191
192
|
|
|
192
|
-
const handleTaskDelete = () => {
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const handleTaskDelete = (taskId?: string) => {
|
|
194
|
+
const id = taskId || selectedTaskId;
|
|
195
|
+
if (!id) return;
|
|
196
|
+
setConfirmAction({ type: 'delete-task', id });
|
|
195
197
|
};
|
|
196
198
|
|
|
197
199
|
const handleConfirmAction = async () => {
|
|
@@ -236,6 +238,18 @@ function WorkspaceInner({ id }: { id: string }) {
|
|
|
236
238
|
}
|
|
237
239
|
};
|
|
238
240
|
|
|
241
|
+
const handleToggleWatch = async () => {
|
|
242
|
+
if (!project) return;
|
|
243
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
244
|
+
method: 'PUT',
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({ watch_enabled: !project.watch_enabled }),
|
|
247
|
+
});
|
|
248
|
+
if (res.ok) {
|
|
249
|
+
setProject(await res.json());
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
239
253
|
// Keyboard shortcuts (use e.code for Korean IME compatibility)
|
|
240
254
|
useEffect(() => {
|
|
241
255
|
const handler = (e: KeyboardEvent) => {
|
|
@@ -304,6 +318,18 @@ function WorkspaceInner({ id }: { id: string }) {
|
|
|
304
318
|
)}
|
|
305
319
|
</div>
|
|
306
320
|
<div className="flex items-center gap-2">
|
|
321
|
+
<button
|
|
322
|
+
onClick={handleToggleWatch}
|
|
323
|
+
className={`px-3 py-1.5 text-xs border rounded-md transition-colors flex items-center gap-1.5 ${
|
|
324
|
+
project.watch_enabled
|
|
325
|
+
? 'bg-success/15 text-success border-success/30 hover:bg-success/25'
|
|
326
|
+
: 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
|
|
327
|
+
}`}
|
|
328
|
+
title={project.watch_enabled ? 'Watch ON — submitted 태스크 자동 실행' : 'Watch OFF'}
|
|
329
|
+
>
|
|
330
|
+
<span className={`inline-block w-2 h-2 rounded-full ${project.watch_enabled ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`} />
|
|
331
|
+
Watch
|
|
332
|
+
</button>
|
|
307
333
|
<button
|
|
308
334
|
onClick={() => setShowAiPolicy(true)}
|
|
309
335
|
className={`px-3 py-1.5 text-xs border rounded-md transition-colors ${
|
|
@@ -391,6 +417,7 @@ function WorkspaceInner({ id }: { id: string }) {
|
|
|
391
417
|
onCreateTask={handleCreateTask}
|
|
392
418
|
onStatusChange={handleTaskStatusChange}
|
|
393
419
|
onTodayToggle={handleTaskTodayToggle}
|
|
420
|
+
onDeleteTask={handleTaskDelete}
|
|
394
421
|
/>
|
|
395
422
|
</div>
|
|
396
423
|
|
package/src/cli.ts
CHANGED
|
@@ -38,6 +38,23 @@ program
|
|
|
38
38
|
await startMcpServer(ctx);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
program
|
|
42
|
+
.command('watch')
|
|
43
|
+
.description('Watch for submitted tasks and auto-execute via Claude CLI')
|
|
44
|
+
.option('--project <id>', 'Watch a specific project (default: all)')
|
|
45
|
+
.option('--interval <seconds>', 'Polling interval in seconds', '10')
|
|
46
|
+
.option('--timeout <minutes>', 'Per-task timeout in minutes', '10')
|
|
47
|
+
.option('--dry-run', 'Show what would be executed without running')
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
const { startWatcher } = await import('@/lib/watcher');
|
|
50
|
+
await startWatcher({
|
|
51
|
+
projectId: opts.project,
|
|
52
|
+
intervalMs: parseInt(opts.interval) * 1000,
|
|
53
|
+
timeoutMs: parseInt(opts.timeout) * 60000,
|
|
54
|
+
dryRun: !!opts.dryRun,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
41
58
|
program
|
|
42
59
|
.command('start')
|
|
43
60
|
.description('Start the web UI (Next.js dev server on port 3456)')
|
|
@@ -22,6 +22,7 @@ export default function ProjectTree({
|
|
|
22
22
|
onCreateTask,
|
|
23
23
|
onStatusChange,
|
|
24
24
|
onTodayToggle,
|
|
25
|
+
onDeleteTask,
|
|
25
26
|
}: {
|
|
26
27
|
subProjects: ISubProjectWithStats[];
|
|
27
28
|
tasks: ITask[];
|
|
@@ -34,6 +35,7 @@ export default function ProjectTree({
|
|
|
34
35
|
onCreateTask: (title: string) => void;
|
|
35
36
|
onStatusChange: (taskId: string, status: TaskStatus) => void;
|
|
36
37
|
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
38
|
+
onDeleteTask: (taskId: string) => void;
|
|
37
39
|
}) {
|
|
38
40
|
const [collapsedSubs, setCollapsedSubs] = useState<Set<string>>(new Set());
|
|
39
41
|
const [addingTaskFor, setAddingTaskFor] = useState<string | null>(null);
|
|
@@ -130,7 +132,7 @@ export default function ProjectTree({
|
|
|
130
132
|
<div
|
|
131
133
|
key={task.id}
|
|
132
134
|
onClick={() => onSelectTask(task.id)}
|
|
133
|
-
className={`flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
135
|
+
className={`group/task flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
134
136
|
selectedTaskId === task.id
|
|
135
137
|
? 'bg-card-hover border-l-primary'
|
|
136
138
|
: 'border-l-transparent hover:bg-card-hover/50'
|
|
@@ -159,6 +161,17 @@ export default function ProjectTree({
|
|
|
159
161
|
*
|
|
160
162
|
</button>
|
|
161
163
|
)}
|
|
164
|
+
<button
|
|
165
|
+
onClick={(e) => {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
onDeleteTask(task.id);
|
|
168
|
+
}}
|
|
169
|
+
className="flex-shrink-0 text-muted-foreground/0 group-hover/task:text-muted-foreground
|
|
170
|
+
hover:!text-destructive transition-colors text-xs px-0.5"
|
|
171
|
+
title="Delete task"
|
|
172
|
+
>
|
|
173
|
+
×
|
|
174
|
+
</button>
|
|
162
175
|
</div>
|
|
163
176
|
))}
|
|
164
177
|
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
-
import type { ITaskConversation } from '@/types';
|
|
4
|
+
import type { ITaskConversation, TaskStatus } from '@/types';
|
|
5
5
|
import ReactMarkdown from 'react-markdown';
|
|
6
6
|
|
|
7
|
+
const POLL_INTERVAL = 3000; // Poll every 3s when task is testing
|
|
8
|
+
|
|
7
9
|
export default function TaskChat({
|
|
8
10
|
basePath,
|
|
11
|
+
taskStatus,
|
|
9
12
|
onApplyToPrompt,
|
|
10
13
|
}: {
|
|
11
14
|
basePath: string;
|
|
15
|
+
taskStatus?: TaskStatus;
|
|
12
16
|
onApplyToPrompt: (content: string) => void;
|
|
13
17
|
}) {
|
|
14
18
|
const [messages, setMessages] = useState<ITaskConversation[]>([]);
|
|
@@ -17,12 +21,26 @@ export default function TaskChat({
|
|
|
17
21
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
18
22
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
const fetchMessages = useCallback(() => {
|
|
21
25
|
fetch(`${basePath}/chat`)
|
|
22
26
|
.then(r => r.json())
|
|
23
|
-
.then(data =>
|
|
27
|
+
.then(data => {
|
|
28
|
+
if (Array.isArray(data)) setMessages(data);
|
|
29
|
+
});
|
|
24
30
|
}, [basePath]);
|
|
25
31
|
|
|
32
|
+
// Initial load
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetchMessages();
|
|
35
|
+
}, [fetchMessages]);
|
|
36
|
+
|
|
37
|
+
// Auto-poll when task is testing (watcher is running)
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (taskStatus !== 'testing') return;
|
|
40
|
+
const interval = setInterval(fetchMessages, POLL_INTERVAL);
|
|
41
|
+
return () => clearInterval(interval);
|
|
42
|
+
}, [taskStatus, fetchMessages]);
|
|
43
|
+
|
|
26
44
|
useEffect(() => {
|
|
27
45
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
28
46
|
}, [messages]);
|
|
@@ -71,6 +89,12 @@ export default function TaskChat({
|
|
|
71
89
|
<div className="flex flex-col h-full border-t border-border">
|
|
72
90
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
|
73
91
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Chat</span>
|
|
92
|
+
{taskStatus === 'testing' && (
|
|
93
|
+
<span className="flex items-center gap-1.5 text-xs text-warning">
|
|
94
|
+
<span className="inline-block w-2 h-2 rounded-full bg-warning animate-pulse" />
|
|
95
|
+
Executing...
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
74
98
|
</div>
|
|
75
99
|
|
|
76
100
|
{/* Messages */}
|
|
@@ -28,11 +28,16 @@ export default function TaskDetail({
|
|
|
28
28
|
|
|
29
29
|
const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
|
|
30
30
|
|
|
31
|
+
// Auto-show chat when task is being executed by watcher
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (task.status === 'testing') setShowChat(true);
|
|
34
|
+
}, [task.status]);
|
|
35
|
+
|
|
31
36
|
// Load prompt
|
|
32
37
|
useEffect(() => {
|
|
33
38
|
setTitle(task.title);
|
|
34
39
|
setDescription(task.description);
|
|
35
|
-
setShowChat(
|
|
40
|
+
setShowChat(task.status === 'testing');
|
|
36
41
|
fetch(`${basePath}/prompt`)
|
|
37
42
|
.then(r => r.json())
|
|
38
43
|
.then(data => setPromptContent(data.content || ''));
|
|
@@ -196,6 +201,7 @@ export default function TaskDetail({
|
|
|
196
201
|
<div className="h-[45%] flex-shrink-0">
|
|
197
202
|
<TaskChat
|
|
198
203
|
basePath={basePath}
|
|
204
|
+
taskStatus={task.status}
|
|
199
205
|
onApplyToPrompt={handleApplyToPrompt}
|
|
200
206
|
/>
|
|
201
207
|
</div>
|
|
@@ -17,6 +17,7 @@ export default function TaskList({
|
|
|
17
17
|
onCreate,
|
|
18
18
|
onStatusChange,
|
|
19
19
|
onTodayToggle,
|
|
20
|
+
onDelete,
|
|
20
21
|
}: {
|
|
21
22
|
tasks: ITask[];
|
|
22
23
|
selectedTaskId: string | null;
|
|
@@ -24,6 +25,7 @@ export default function TaskList({
|
|
|
24
25
|
onCreate: (title: string) => void;
|
|
25
26
|
onStatusChange: (taskId: string, status: TaskStatus) => void;
|
|
26
27
|
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
28
|
+
onDelete: (taskId: string) => void;
|
|
27
29
|
}) {
|
|
28
30
|
const [newTitle, setNewTitle] = useState('');
|
|
29
31
|
const [adding, setAdding] = useState(false);
|
|
@@ -48,7 +50,7 @@ export default function TaskList({
|
|
|
48
50
|
<div
|
|
49
51
|
key={task.id}
|
|
50
52
|
onClick={() => onSelect(task.id)}
|
|
51
|
-
className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
53
|
+
className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
52
54
|
selectedTaskId === task.id
|
|
53
55
|
? 'bg-card-hover border-l-primary'
|
|
54
56
|
: 'border-l-transparent hover:bg-card-hover/50'
|
|
@@ -77,6 +79,17 @@ export default function TaskList({
|
|
|
77
79
|
*
|
|
78
80
|
</button>
|
|
79
81
|
)}
|
|
82
|
+
<button
|
|
83
|
+
onClick={(e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
onDelete(task.id);
|
|
86
|
+
}}
|
|
87
|
+
className="flex-shrink-0 text-muted-foreground/0 group-hover:text-muted-foreground
|
|
88
|
+
hover:!text-destructive transition-colors text-xs px-0.5"
|
|
89
|
+
title="Delete task"
|
|
90
|
+
>
|
|
91
|
+
×
|
|
92
|
+
</button>
|
|
80
93
|
</div>
|
|
81
94
|
))}
|
|
82
95
|
</div>
|
package/src/lib/ai/client.ts
CHANGED
|
@@ -8,11 +8,16 @@ const MAX_TURNS = 80;
|
|
|
8
8
|
export type OnTextChunk = (text: string) => void;
|
|
9
9
|
export type OnRawEvent = (event: Record<string, unknown>) => void;
|
|
10
10
|
|
|
11
|
+
export interface RunClaudeOptions {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* Spawn Claude Code CLI and collect the result text.
|
|
13
18
|
* Optional onText callback receives streaming text chunks as they arrive.
|
|
14
19
|
*/
|
|
15
|
-
export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<string> {
|
|
20
|
+
export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent, options?: RunClaudeOptions): Promise<string> {
|
|
16
21
|
return new Promise((resolve, reject) => {
|
|
17
22
|
const useStreamJson = !!(onText || onRawEvent);
|
|
18
23
|
const args = [
|
|
@@ -35,11 +40,21 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
const proc = spawn(CLI_PATH, args, {
|
|
38
|
-
cwd: process.cwd(),
|
|
43
|
+
cwd: options?.cwd || process.cwd(),
|
|
39
44
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
45
|
env: { ...cleanEnv, FORCE_COLOR: '0' },
|
|
41
46
|
});
|
|
42
47
|
|
|
48
|
+
// Timeout handling
|
|
49
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
if (options?.timeoutMs) {
|
|
52
|
+
timeoutTimer = setTimeout(() => {
|
|
53
|
+
timedOut = true;
|
|
54
|
+
proc.kill('SIGTERM');
|
|
55
|
+
}, options.timeoutMs);
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
// Write prompt to stdin and close it
|
|
44
59
|
proc.stdin?.write(prompt);
|
|
45
60
|
proc.stdin?.end();
|
|
@@ -99,10 +114,15 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
|
|
|
99
114
|
});
|
|
100
115
|
|
|
101
116
|
proc.on('exit', (code, signal) => {
|
|
117
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
102
118
|
// Clean up known CLI noise from text output
|
|
103
119
|
if (!useStreamJson) {
|
|
104
120
|
resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
|
|
105
121
|
}
|
|
122
|
+
if (timedOut) {
|
|
123
|
+
reject(new Error(`Claude CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
106
126
|
if (code !== 0 && !resultText) {
|
|
107
127
|
const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
|
|
108
128
|
reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
|
|
@@ -2,14 +2,31 @@ import { getDb } from '../index';
|
|
|
2
2
|
import { generateId } from '../../utils/id';
|
|
3
3
|
import type { IProject } from '@/types';
|
|
4
4
|
|
|
5
|
+
interface ProjectRow {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
project_path: string | null;
|
|
10
|
+
ai_context: string;
|
|
11
|
+
watch_enabled: number;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rowToProject(row: ProjectRow): IProject {
|
|
17
|
+
return { ...row, watch_enabled: row.watch_enabled === 1 };
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
export function listProjects(): IProject[] {
|
|
6
21
|
const db = getDb();
|
|
7
|
-
|
|
22
|
+
const rows = db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as ProjectRow[];
|
|
23
|
+
return rows.map(rowToProject);
|
|
8
24
|
}
|
|
9
25
|
|
|
10
26
|
export function getProject(id: string): IProject | undefined {
|
|
11
27
|
const db = getDb();
|
|
12
|
-
|
|
28
|
+
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow | undefined;
|
|
29
|
+
return row ? rowToProject(row) : undefined;
|
|
13
30
|
}
|
|
14
31
|
|
|
15
32
|
export function createProject(name: string, description: string = '', projectPath?: string): IProject {
|
|
@@ -30,7 +47,7 @@ export function createProject(name: string, description: string = '', projectPat
|
|
|
30
47
|
return getProject(id)!;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string }): IProject | undefined {
|
|
50
|
+
export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean }): IProject | undefined {
|
|
34
51
|
const db = getDb();
|
|
35
52
|
const project = getProject(id);
|
|
36
53
|
if (!project) return undefined;
|
|
@@ -38,12 +55,13 @@ export function updateProject(id: string, data: { name?: string; description?: s
|
|
|
38
55
|
const now = new Date().toISOString();
|
|
39
56
|
|
|
40
57
|
db.prepare(
|
|
41
|
-
'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, updated_at = ? WHERE id = ?'
|
|
58
|
+
'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, updated_at = ? WHERE id = ?'
|
|
42
59
|
).run(
|
|
43
60
|
data.name ?? project.name,
|
|
44
61
|
data.description ?? project.description,
|
|
45
62
|
data.project_path !== undefined ? data.project_path : project.project_path,
|
|
46
63
|
data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
|
|
64
|
+
data.watch_enabled !== undefined ? (data.watch_enabled ? 1 : 0) : (project.watch_enabled ? 1 : 0),
|
|
47
65
|
now,
|
|
48
66
|
id,
|
|
49
67
|
);
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -30,6 +30,9 @@ export function initSchema(db: Database.Database): void {
|
|
|
30
30
|
if (!projCols.some(c => c.name === 'ai_context')) {
|
|
31
31
|
db.exec("ALTER TABLE projects ADD COLUMN ai_context TEXT NOT NULL DEFAULT ''");
|
|
32
32
|
}
|
|
33
|
+
if (!projCols.some(c => c.name === 'watch_enabled')) {
|
|
34
|
+
db.exec("ALTER TABLE projects ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0");
|
|
35
|
+
}
|
|
33
36
|
|
|
34
37
|
// v2 tables
|
|
35
38
|
db.exec(`
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { runClaude } from './ai/client';
|
|
3
|
+
import { listProjects, getProject } from './db/queries/projects';
|
|
4
|
+
import { getSubProject } from './db/queries/sub-projects';
|
|
5
|
+
import { getTasksByProject, getTask, updateTask } from './db/queries/tasks';
|
|
6
|
+
import { getTaskPrompt } from './db/queries/task-prompts';
|
|
7
|
+
import { addTaskConversation } from './db/queries/task-conversations';
|
|
8
|
+
import type { ITask, IProject } from '@/types';
|
|
9
|
+
|
|
10
|
+
export interface WatcherOptions {
|
|
11
|
+
projectId?: string;
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
dryRun: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PROGRESS_SAVE_INTERVAL = 5000; // Save streaming output to DB every 5s
|
|
18
|
+
|
|
19
|
+
function timestamp(): string {
|
|
20
|
+
return new Date().toLocaleTimeString('ko-KR', { hour12: false });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function log(msg: string) {
|
|
24
|
+
console.log(`[IM Watch] ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function logTask(msg: string) {
|
|
28
|
+
console.log(` ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatDuration(ms: number): string {
|
|
32
|
+
const s = Math.floor(ms / 1000);
|
|
33
|
+
if (s < 60) return `${s}s`;
|
|
34
|
+
const m = Math.floor(s / 60);
|
|
35
|
+
const rem = s % 60;
|
|
36
|
+
return `${m}m ${rem}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveCwd(task: ITask, project: IProject): string | null {
|
|
40
|
+
const subProject = getSubProject(task.sub_project_id);
|
|
41
|
+
if (subProject?.folder_path && fs.existsSync(subProject.folder_path)) {
|
|
42
|
+
return subProject.folder_path;
|
|
43
|
+
}
|
|
44
|
+
if (project.project_path && fs.existsSync(project.project_path)) {
|
|
45
|
+
return project.project_path;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function executeTask(task: ITask, project: IProject, options: WatcherOptions): Promise<void> {
|
|
51
|
+
const cwd = resolveCwd(task, project);
|
|
52
|
+
if (!cwd) {
|
|
53
|
+
logTask(`⚠ Skip "${task.title}" — no folder_path or project_path set`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const prompt = getTaskPrompt(task.id);
|
|
58
|
+
if (!prompt?.content?.trim()) {
|
|
59
|
+
logTask(`⚠ Skip "${task.title}" — no prompt content`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fresh = getTask(task.id);
|
|
64
|
+
if (!fresh || fresh.status !== 'submitted') {
|
|
65
|
+
logTask(`⚠ Skip "${task.title}" — status already changed`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const subProject = getSubProject(task.sub_project_id);
|
|
70
|
+
const subName = subProject?.name || 'unknown';
|
|
71
|
+
|
|
72
|
+
console.log(`[${timestamp()}] ▶ "${task.title}" (sub: ${subName}, cwd: ${cwd})`);
|
|
73
|
+
|
|
74
|
+
if (options.dryRun) {
|
|
75
|
+
logTask(`[DRY RUN] Would execute prompt (${prompt.content.length} chars)`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Transition: submitted → testing
|
|
80
|
+
updateTask(task.id, { status: 'testing' });
|
|
81
|
+
logTask(`submitted → testing`);
|
|
82
|
+
addTaskConversation(task.id, 'user', `[watch] 실행 시작`);
|
|
83
|
+
|
|
84
|
+
const startTime = Date.now();
|
|
85
|
+
|
|
86
|
+
let fullPrompt = prompt.content;
|
|
87
|
+
if (project.ai_context) {
|
|
88
|
+
fullPrompt = `Project AI Policy:\n${project.ai_context}\n\n---\n\n${fullPrompt}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logTask('────────────────────────────────');
|
|
92
|
+
|
|
93
|
+
// Accumulate streaming output and periodically save to DB
|
|
94
|
+
let accumulated = '';
|
|
95
|
+
let progressMsgId: string | null = null;
|
|
96
|
+
let lastSaveTime = Date.now();
|
|
97
|
+
|
|
98
|
+
const saveProgress = () => {
|
|
99
|
+
if (!accumulated.trim()) return;
|
|
100
|
+
const content = `[진행 중]\n${accumulated}`;
|
|
101
|
+
if (progressMsgId) {
|
|
102
|
+
// Update existing progress message
|
|
103
|
+
const { getDb } = require('./db/index');
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(content, progressMsgId);
|
|
106
|
+
} else {
|
|
107
|
+
const msg = addTaskConversation(task.id, 'assistant', content);
|
|
108
|
+
progressMsgId = msg.id;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const onText = (chunk: string) => {
|
|
114
|
+
process.stdout.write(chunk);
|
|
115
|
+
accumulated += chunk;
|
|
116
|
+
|
|
117
|
+
// Save to DB periodically
|
|
118
|
+
if (Date.now() - lastSaveTime > PROGRESS_SAVE_INTERVAL) {
|
|
119
|
+
saveProgress();
|
|
120
|
+
lastSaveTime = Date.now();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const result = await runClaude(fullPrompt, onText, undefined, {
|
|
125
|
+
cwd,
|
|
126
|
+
timeoutMs: options.timeoutMs,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
process.stdout.write('\n');
|
|
130
|
+
logTask('────────────────────────────────');
|
|
131
|
+
|
|
132
|
+
const duration = Date.now() - startTime;
|
|
133
|
+
updateTask(task.id, { status: 'done' });
|
|
134
|
+
|
|
135
|
+
// Replace progress message with final result
|
|
136
|
+
if (progressMsgId) {
|
|
137
|
+
const { getDb } = require('./db/index');
|
|
138
|
+
const db = getDb();
|
|
139
|
+
db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(result || '(no output)', progressMsgId);
|
|
140
|
+
} else {
|
|
141
|
+
addTaskConversation(task.id, 'assistant', result || '(no output)');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(`[${timestamp()}] ✓ Done (${formatDuration(duration)})`);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
const duration = Date.now() - startTime;
|
|
147
|
+
process.stdout.write('\n');
|
|
148
|
+
logTask('────────────────────────────────');
|
|
149
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
150
|
+
updateTask(task.id, { status: 'problem' });
|
|
151
|
+
|
|
152
|
+
// Replace progress message with error
|
|
153
|
+
if (progressMsgId) {
|
|
154
|
+
const { getDb } = require('./db/index');
|
|
155
|
+
const db = getDb();
|
|
156
|
+
db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(`[error] ${errorMsg}`, progressMsgId);
|
|
157
|
+
} else {
|
|
158
|
+
addTaskConversation(task.id, 'assistant', `[error] ${errorMsg}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(`[${timestamp()}] ✗ Failed (${formatDuration(duration)}): ${errorMsg}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function startWatcher(options: WatcherOptions): Promise<void> {
|
|
166
|
+
if (options.projectId) {
|
|
167
|
+
const project = getProject(options.projectId);
|
|
168
|
+
if (!project) {
|
|
169
|
+
console.error(`Error: Project "${options.projectId}" not found.`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
log(`Watching project: "${project.name}" (${project.id})`);
|
|
173
|
+
} else {
|
|
174
|
+
log(`Watching all watch-enabled projects`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log(`Polling every ${options.intervalMs / 1000}s | Timeout: ${options.timeoutMs / 60000}m${options.dryRun ? ' | DRY RUN' : ''}`);
|
|
178
|
+
log('─'.repeat(50));
|
|
179
|
+
log('Press Ctrl+C to stop\n');
|
|
180
|
+
|
|
181
|
+
let isProcessing = false;
|
|
182
|
+
let shuttingDown = false;
|
|
183
|
+
|
|
184
|
+
const poll = async () => {
|
|
185
|
+
if (isProcessing || shuttingDown) return;
|
|
186
|
+
isProcessing = true;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Only process watch-enabled projects
|
|
190
|
+
const projects = options.projectId
|
|
191
|
+
? [getProject(options.projectId)!].filter(p => p)
|
|
192
|
+
: listProjects().filter(p => p.watch_enabled);
|
|
193
|
+
|
|
194
|
+
const submittedTasks: { task: ITask; project: IProject }[] = [];
|
|
195
|
+
|
|
196
|
+
for (const project of projects) {
|
|
197
|
+
const tasks = getTasksByProject(project.id).filter(t => t.status === 'submitted');
|
|
198
|
+
for (const task of tasks) {
|
|
199
|
+
submittedTasks.push({ task, project });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (submittedTasks.length > 0) {
|
|
204
|
+
console.log(`[${timestamp()}] Found ${submittedTasks.length} submitted task(s)`);
|
|
205
|
+
for (const { task, project } of submittedTasks) {
|
|
206
|
+
if (shuttingDown) break;
|
|
207
|
+
await executeTask(task, project, options);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
212
|
+
console.error(`[${timestamp()}] Poll error: ${msg}`);
|
|
213
|
+
} finally {
|
|
214
|
+
isProcessing = false;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const shutdown = () => {
|
|
219
|
+
if (shuttingDown) return;
|
|
220
|
+
shuttingDown = true;
|
|
221
|
+
console.log(`\n[IM Watch] Shutting down...${isProcessing ? ' (waiting for current task)' : ''}`);
|
|
222
|
+
if (!isProcessing) process.exit(0);
|
|
223
|
+
const checkDone = setInterval(() => {
|
|
224
|
+
if (!isProcessing) {
|
|
225
|
+
clearInterval(checkDone);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
}, 500);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
process.on('SIGINT', shutdown);
|
|
232
|
+
process.on('SIGTERM', shutdown);
|
|
233
|
+
|
|
234
|
+
await poll();
|
|
235
|
+
setInterval(poll, options.intervalMs);
|
|
236
|
+
}
|