@silbaram/artifact-driven-agent 0.1.6 → 0.1.9
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 +709 -516
- package/ai-dev-team/.ada-status.json +10 -0
- package/ai-dev-team/.ada-version +6 -0
- package/ai-dev-team/.current-template +1 -0
- package/ai-dev-team/.sessions/logs/20260124-014551-00f04724.log +5 -0
- package/ai-dev-team/.sessions/logs/20260124-014623-cb2b1d44.log +5 -0
- package/ai-dev-team/ada.config.json +15 -0
- package/ai-dev-team/artifacts/api.md +212 -0
- package/ai-dev-team/artifacts/decision.md +72 -0
- package/ai-dev-team/artifacts/improvement-reports/IMP-0000-template.md +57 -0
- package/ai-dev-team/artifacts/plan.md +187 -0
- package/ai-dev-team/artifacts/project.md +193 -0
- package/ai-dev-team/artifacts/sprints/_template/docs/release-notes.md +37 -0
- package/ai-dev-team/artifacts/sprints/_template/meta.md +54 -0
- package/ai-dev-team/artifacts/sprints/_template/retrospective.md +50 -0
- package/ai-dev-team/artifacts/sprints/_template/review-reports/review-template.md +49 -0
- package/ai-dev-team/artifacts/sprints/_template/tasks/task-template.md +43 -0
- package/ai-dev-team/artifacts/ui.md +104 -0
- package/ai-dev-team/roles/analyzer.md +265 -0
- package/ai-dev-team/roles/developer.md +222 -0
- package/ai-dev-team/roles/documenter.md +715 -0
- package/ai-dev-team/roles/improver.md +461 -0
- package/ai-dev-team/roles/manager.md +544 -0
- package/ai-dev-team/roles/planner.md +398 -0
- package/ai-dev-team/roles/reviewer.md +294 -0
- package/ai-dev-team/rules/api-change.md +198 -0
- package/ai-dev-team/rules/document-priority.md +199 -0
- package/ai-dev-team/rules/escalation.md +172 -0
- package/ai-dev-team/rules/iteration.md +236 -0
- package/ai-dev-team/rules/rfc.md +31 -0
- package/ai-dev-team/rules/rollback.md +218 -0
- package/bin/cli.js +49 -5
- package/core/artifacts/sprints/_template/meta.md +4 -4
- package/core/docs-templates/mkdocs/docs/architecture/overview.md +29 -0
- package/core/docs-templates/mkdocs/docs/changelog.md +36 -0
- package/core/docs-templates/mkdocs/docs/contributing/contributing.md +60 -0
- package/core/docs-templates/mkdocs/docs/getting-started/configuration.md +51 -0
- package/core/docs-templates/mkdocs/docs/getting-started/installation.md +41 -0
- package/core/docs-templates/mkdocs/docs/getting-started/quick-start.md +56 -0
- package/core/docs-templates/mkdocs/docs/guides/api-reference.md +83 -0
- package/core/docs-templates/mkdocs/docs/index.md +32 -0
- package/core/docs-templates/mkdocs/mkdocs.yml +86 -0
- package/core/roles/analyzer.md +32 -10
- package/core/roles/developer.md +222 -223
- package/core/roles/documenter.md +592 -170
- package/core/roles/improver.md +461 -0
- package/core/roles/manager.md +4 -1
- package/core/roles/planner.md +160 -10
- package/core/roles/reviewer.md +31 -3
- package/core/rules/document-priority.md +2 -1
- package/core/rules/rollback.md +3 -3
- package/package.json +1 -1
- package/src/commands/config.js +371 -0
- package/src/commands/docs.js +502 -0
- package/src/commands/interactive.js +324 -33
- package/src/commands/monitor.js +236 -0
- package/src/commands/run.js +360 -122
- package/src/commands/sessions.js +270 -70
- package/src/commands/setup.js +22 -1
- package/src/commands/sprint.js +295 -54
- package/src/commands/status.js +34 -1
- package/src/commands/upgrade.js +416 -0
- package/src/commands/validate.js +4 -3
- package/src/index.js +1 -0
- package/src/ui/dashboard.js +518 -0
- package/src/ui/keyHandler.js +147 -0
- package/src/ui/quickActions.js +111 -0
- package/src/utils/config.js +74 -0
- package/src/utils/files.js +70 -3
- package/src/utils/sessionState.js +472 -328
- package/src/utils/sessionState.process.test.js +101 -0
- package/src/utils/sessionState.test.js +183 -0
- package/src/utils/sprintUtils.js +134 -0
- package/src/utils/taskParser.js +134 -0
- package/src/utils/taskParser.test.js +76 -0
- package/ai-dev-team/artifacts/features/_template/qa.md +0 -16
- package/examples/todo-app/README.md +0 -23
- package/examples/todo-app/artifacts/backlog.md +0 -23
- package/examples/todo-app/artifacts/plan.md +0 -23
- package/examples/todo-app/artifacts/project.md +0 -23
|
@@ -1,369 +1,513 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getWorkspaceDir } from './files.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* .ada-status.json 파일 경로 반환
|
|
7
|
-
*/
|
|
8
|
-
export function getStatusFilePath() {
|
|
9
|
-
return path.join(getWorkspaceDir(), '.ada-status.json');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 상태 파일 템플릿
|
|
14
|
-
*/
|
|
15
|
-
function getEmptyStatus() {
|
|
16
|
-
return {
|
|
17
|
-
version: '1.0',
|
|
18
|
-
updatedAt: new Date().toISOString(),
|
|
19
|
-
currentPhase: 'planning',
|
|
20
|
-
activeSessions: [],
|
|
21
|
-
pendingQuestions: [],
|
|
22
|
-
taskProgress: {},
|
|
23
|
-
notifications: [],
|
|
24
|
-
locks: {}
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 상태 파일 읽기 (없으면 초기화)
|
|
30
|
-
* 동시 접근 시 재시도 로직 포함
|
|
31
|
-
*/
|
|
32
|
-
export function readStatus(retries = 3) {
|
|
33
|
-
const statusFile = getStatusFilePath();
|
|
34
|
-
|
|
35
|
-
for (let i = 0; i < retries; i++) {
|
|
36
|
-
try {
|
|
37
|
-
if (!fs.existsSync(statusFile)) {
|
|
38
|
-
const initialStatus = getEmptyStatus();
|
|
39
|
-
fs.writeFileSync(statusFile, JSON.stringify(initialStatus, null, 2));
|
|
40
|
-
return initialStatus;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
let content = fs.readFileSync(statusFile, 'utf-8');
|
|
44
|
-
try {
|
|
45
|
-
const status = JSON.parse(content);
|
|
46
|
-
// 스키마 검증 및 누락된 필드 추가
|
|
47
|
-
const emptyStatus = getEmptyStatus();
|
|
48
|
-
Object.keys(emptyStatus).forEach(key => {
|
|
49
|
-
if (status[key] === undefined) {
|
|
50
|
-
status[key] = emptyStatus[key];
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
return status;
|
|
54
|
-
} catch (parseError) {
|
|
55
|
-
if (parseError instanceof SyntaxError && parseError.message.includes('JSON')) {
|
|
56
|
-
// 파일 내용에서 백슬래시 이스케이프 처리
|
|
57
|
-
content = content.replace(/\\/g, '\\\\');
|
|
58
|
-
const status = JSON.parse(content);
|
|
59
|
-
|
|
60
|
-
// 스키마 검증 및 누락된 필드 추가
|
|
61
|
-
const emptyStatus = getEmptyStatus();
|
|
62
|
-
Object.keys(emptyStatus).forEach(key => {
|
|
63
|
-
if (status[key] === undefined) {
|
|
64
|
-
status[key] = emptyStatus[key];
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// 복구된 파일 저장
|
|
69
|
-
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
|
|
70
|
-
return status;
|
|
71
|
-
}
|
|
72
|
-
throw parseError;
|
|
73
|
-
}
|
|
74
|
-
} catch (err) {
|
|
75
|
-
if (i === retries - 1) {
|
|
76
|
-
console.error('⚠️ 상태 파일 읽기 실패:', err.message);
|
|
77
|
-
return getEmptyStatus();
|
|
78
|
-
}
|
|
79
|
-
// 짧은 대기 후 재시도
|
|
80
|
-
const delay = Math.random() * 100;
|
|
81
|
-
const start = Date.now();
|
|
82
|
-
while (Date.now() - start < delay) { /* busy wait */ }
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return getEmptyStatus();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* 상태 파일 쓰기
|
|
91
|
-
* 동시 접근 시 재시도 로직 포함
|
|
92
|
-
*/
|
|
93
|
-
export function writeStatus(status, retries = 3) {
|
|
94
|
-
const statusFile = getStatusFilePath();
|
|
95
|
-
status.updatedAt = new Date().toISOString();
|
|
96
|
-
|
|
97
|
-
for (let i = 0; i < retries; i++) {
|
|
98
|
-
try {
|
|
99
|
-
// 임시 파일에 먼저 쓰고 atomic rename
|
|
100
|
-
const tempFile = `${statusFile}.tmp`;
|
|
101
|
-
fs.writeFileSync(tempFile, JSON.stringify(status, null, 2));
|
|
102
|
-
fs.renameSync(tempFile, statusFile);
|
|
103
|
-
return true;
|
|
104
|
-
} catch (err) {
|
|
105
|
-
if (i === retries - 1) {
|
|
106
|
-
console.error('⚠️ 상태 파일 쓰기 실패:', err.message);
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
// 짧은 대기 후 재시도
|
|
110
|
-
const delay = Math.random() * 100;
|
|
111
|
-
const start = Date.now();
|
|
112
|
-
while (Date.now() - start < delay) { /* busy wait */ }
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* 세션 등록
|
|
121
|
-
*/
|
|
122
|
-
export function registerSession(sessionId, role, tool) {
|
|
123
|
-
const status = readStatus();
|
|
124
|
-
|
|
125
|
-
// 기존 세션 중복 확인
|
|
126
|
-
const existing = status.activeSessions.find(s => s.sessionId === sessionId);
|
|
127
|
-
if (existing) {
|
|
128
|
-
return status;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
status.activeSessions.push({
|
|
132
|
-
sessionId,
|
|
133
|
-
role,
|
|
134
|
-
tool,
|
|
135
|
-
startedAt: new Date().toISOString(),
|
|
136
|
-
status: 'active'
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
writeStatus(status);
|
|
140
|
-
return status;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* 세션 제거
|
|
145
|
-
*/
|
|
146
|
-
export function unregisterSession(sessionId) {
|
|
147
|
-
const status = readStatus();
|
|
148
|
-
|
|
149
|
-
status.activeSessions = status.activeSessions.filter(s => s.sessionId !== sessionId);
|
|
150
|
-
|
|
151
|
-
// 해당 세션이 보유한 잠금 해제
|
|
152
|
-
Object.keys(status.locks).forEach(file => {
|
|
153
|
-
if (status.locks[file].holder === sessionId) {
|
|
154
|
-
delete status.locks[file];
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
writeStatus(status);
|
|
159
|
-
return status;
|
|
3
|
+
import { getWorkspaceDir, getSessionsDir, getTimestamp } from './files.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* .ada-status.json 파일 경로 반환
|
|
7
|
+
*/
|
|
8
|
+
export function getStatusFilePath() {
|
|
9
|
+
return path.join(getWorkspaceDir(), '.ada-status.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 상태 파일 템플릿
|
|
14
|
+
*/
|
|
15
|
+
function getEmptyStatus() {
|
|
16
|
+
return {
|
|
17
|
+
version: '1.0',
|
|
18
|
+
updatedAt: new Date().toISOString(),
|
|
19
|
+
currentPhase: 'planning',
|
|
20
|
+
activeSessions: [],
|
|
21
|
+
pendingQuestions: [],
|
|
22
|
+
taskProgress: {},
|
|
23
|
+
notifications: [],
|
|
24
|
+
locks: {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 상태 파일 읽기 (없으면 초기화)
|
|
30
|
+
* 동시 접근 시 재시도 로직 포함
|
|
31
|
+
*/
|
|
32
|
+
export function readStatus(retries = 3) {
|
|
33
|
+
const statusFile = getStatusFilePath();
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < retries; i++) {
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(statusFile)) {
|
|
38
|
+
const initialStatus = getEmptyStatus();
|
|
39
|
+
fs.writeFileSync(statusFile, JSON.stringify(initialStatus, null, 2));
|
|
40
|
+
return initialStatus;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let content = fs.readFileSync(statusFile, 'utf-8');
|
|
44
|
+
try {
|
|
45
|
+
const status = JSON.parse(content);
|
|
46
|
+
// 스키마 검증 및 누락된 필드 추가
|
|
47
|
+
const emptyStatus = getEmptyStatus();
|
|
48
|
+
Object.keys(emptyStatus).forEach(key => {
|
|
49
|
+
if (status[key] === undefined) {
|
|
50
|
+
status[key] = emptyStatus[key];
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return status;
|
|
54
|
+
} catch (parseError) {
|
|
55
|
+
if (parseError instanceof SyntaxError && parseError.message.includes('JSON')) {
|
|
56
|
+
// 파일 내용에서 백슬래시 이스케이프 처리
|
|
57
|
+
content = content.replace(/\\/g, '\\\\');
|
|
58
|
+
const status = JSON.parse(content);
|
|
59
|
+
|
|
60
|
+
// 스키마 검증 및 누락된 필드 추가
|
|
61
|
+
const emptyStatus = getEmptyStatus();
|
|
62
|
+
Object.keys(emptyStatus).forEach(key => {
|
|
63
|
+
if (status[key] === undefined) {
|
|
64
|
+
status[key] = emptyStatus[key];
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 복구된 파일 저장
|
|
69
|
+
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
72
|
+
throw parseError;
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (i === retries - 1) {
|
|
76
|
+
console.error('⚠️ 상태 파일 읽기 실패:', err.message);
|
|
77
|
+
return getEmptyStatus();
|
|
78
|
+
}
|
|
79
|
+
// 짧은 대기 후 재시도
|
|
80
|
+
const delay = Math.random() * 100;
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
while (Date.now() - start < delay) { /* busy wait */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return getEmptyStatus();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 상태 파일 쓰기
|
|
91
|
+
* 동시 접근 시 재시도 로직 포함
|
|
92
|
+
*/
|
|
93
|
+
export function writeStatus(status, retries = 3) {
|
|
94
|
+
const statusFile = getStatusFilePath();
|
|
95
|
+
status.updatedAt = new Date().toISOString();
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < retries; i++) {
|
|
98
|
+
try {
|
|
99
|
+
// 임시 파일에 먼저 쓰고 atomic rename
|
|
100
|
+
const tempFile = `${statusFile}.tmp`;
|
|
101
|
+
fs.writeFileSync(tempFile, JSON.stringify(status, null, 2));
|
|
102
|
+
fs.renameSync(tempFile, statusFile);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (i === retries - 1) {
|
|
106
|
+
console.error('⚠️ 상태 파일 쓰기 실패:', err.message);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
// 짧은 대기 후 재시도
|
|
110
|
+
const delay = Math.random() * 100;
|
|
111
|
+
const start = Date.now();
|
|
112
|
+
while (Date.now() - start < delay) { /* busy wait */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 세션 등록
|
|
121
|
+
*/
|
|
122
|
+
export function registerSession(sessionId, role, tool) {
|
|
123
|
+
const status = readStatus();
|
|
124
|
+
|
|
125
|
+
// 기존 세션 중복 확인
|
|
126
|
+
const existing = status.activeSessions.find(s => s.sessionId === sessionId);
|
|
127
|
+
if (existing) {
|
|
128
|
+
return status;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
status.activeSessions.push({
|
|
132
|
+
sessionId,
|
|
133
|
+
role,
|
|
134
|
+
tool,
|
|
135
|
+
startedAt: new Date().toISOString(),
|
|
136
|
+
status: 'active'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
writeStatus(status);
|
|
140
|
+
return status;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 세션 제거
|
|
145
|
+
*/
|
|
146
|
+
export function unregisterSession(sessionId) {
|
|
147
|
+
const status = readStatus();
|
|
148
|
+
|
|
149
|
+
status.activeSessions = status.activeSessions.filter(s => s.sessionId !== sessionId);
|
|
150
|
+
|
|
151
|
+
// 해당 세션이 보유한 잠금 해제
|
|
152
|
+
Object.keys(status.locks).forEach(file => {
|
|
153
|
+
if (status.locks[file].holder === sessionId) {
|
|
154
|
+
delete status.locks[file];
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
writeStatus(status);
|
|
159
|
+
return status;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 세션 상태 업데이트
|
|
164
|
+
*/
|
|
165
|
+
export function updateSessionStatus(sessionId, newStatus) {
|
|
166
|
+
return updateSessionDetails(sessionId, { status: newStatus });
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
/**
|
|
163
|
-
* 세션
|
|
170
|
+
* 세션 메타데이터 업데이트
|
|
164
171
|
*/
|
|
165
|
-
export function
|
|
172
|
+
export function updateSessionDetails(sessionId, updates = {}) {
|
|
166
173
|
const status = readStatus();
|
|
167
174
|
|
|
168
175
|
const session = status.activeSessions.find(s => s.sessionId === sessionId);
|
|
169
176
|
if (session) {
|
|
170
|
-
session
|
|
177
|
+
Object.assign(session, updates);
|
|
171
178
|
session.lastUpdate = new Date().toISOString();
|
|
172
179
|
writeStatus(status);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
return status;
|
|
176
183
|
}
|
|
177
|
-
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 질문 추가
|
|
187
|
+
*/
|
|
188
|
+
export function addQuestion(from, to, question, options = [], priority = 'normal') {
|
|
189
|
+
const status = readStatus();
|
|
190
|
+
|
|
191
|
+
const questionId = `Q${from.substring(0, 1).toUpperCase()}${String(status.pendingQuestions.length + 1).padStart(3, '0')}`;
|
|
192
|
+
|
|
193
|
+
const newQuestion = {
|
|
194
|
+
id: questionId,
|
|
195
|
+
from,
|
|
196
|
+
to,
|
|
197
|
+
question,
|
|
198
|
+
options,
|
|
199
|
+
priority,
|
|
200
|
+
status: 'waiting',
|
|
201
|
+
createdAt: new Date().toISOString()
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
status.pendingQuestions.push(newQuestion);
|
|
205
|
+
|
|
206
|
+
// 알림 추가 (to 대상 지정)
|
|
207
|
+
addNotificationInternal(status, 'question', from, `새 질문 [${questionId}]: ${question}`, to);
|
|
208
|
+
|
|
209
|
+
writeStatus(status);
|
|
210
|
+
return questionId;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 질문 응답
|
|
215
|
+
*/
|
|
216
|
+
export function answerQuestion(questionId, answer) {
|
|
217
|
+
const status = readStatus();
|
|
218
|
+
|
|
219
|
+
const question = status.pendingQuestions.find(q => q.id === questionId);
|
|
220
|
+
if (question) {
|
|
221
|
+
question.status = 'answered';
|
|
222
|
+
question.answer = answer;
|
|
223
|
+
question.answeredAt = new Date().toISOString();
|
|
224
|
+
|
|
225
|
+
// 질문 관련 알림을 읽음으로 표시
|
|
226
|
+
status.notifications.forEach(n => {
|
|
227
|
+
if (n.message.includes(questionId) && !n.read) {
|
|
228
|
+
n.read = true;
|
|
229
|
+
n.readAt = new Date().toISOString();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// 응답 알림 추가 (질문자에게)
|
|
234
|
+
addNotificationInternal(status, 'info', 'manager', `질문 ${questionId} 응답됨: ${answer}`, question.from);
|
|
235
|
+
|
|
236
|
+
writeStatus(status);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return status;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Task 진행률 업데이트
|
|
244
|
+
*/
|
|
245
|
+
export function updateTaskProgress(taskId, updates) {
|
|
246
|
+
const status = readStatus();
|
|
247
|
+
|
|
248
|
+
if (!status.taskProgress[taskId]) {
|
|
249
|
+
status.taskProgress[taskId] = {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
Object.assign(status.taskProgress[taskId], updates, {
|
|
253
|
+
lastUpdate: new Date().toISOString()
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
writeStatus(status);
|
|
257
|
+
return status;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 알림 추가 (내부용)
|
|
262
|
+
*/
|
|
263
|
+
function addNotificationInternal(status, type, from, message, to = 'all') {
|
|
264
|
+
const notificationId = `N${String(status.notifications.length + 1).padStart(3, '0')}`;
|
|
265
|
+
|
|
266
|
+
status.notifications.push({
|
|
267
|
+
id: notificationId,
|
|
268
|
+
type,
|
|
269
|
+
from,
|
|
270
|
+
to,
|
|
271
|
+
message,
|
|
272
|
+
read: false,
|
|
273
|
+
createdAt: new Date().toISOString()
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// 알림은 최근 50개만 유지
|
|
277
|
+
if (status.notifications.length > 50) {
|
|
278
|
+
status.notifications = status.notifications.slice(-50);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 알림 추가 (외부용)
|
|
284
|
+
*/
|
|
285
|
+
export function addNotification(type, from, message, to = 'all') {
|
|
286
|
+
const status = readStatus();
|
|
287
|
+
addNotificationInternal(status, type, from, message, to);
|
|
288
|
+
writeStatus(status);
|
|
289
|
+
return status;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 알림을 읽음으로 표시
|
|
294
|
+
*/
|
|
295
|
+
export function markNotificationAsRead(notificationId) {
|
|
296
|
+
const status = readStatus();
|
|
297
|
+
|
|
298
|
+
const notification = status.notifications.find(n => n.id === notificationId);
|
|
299
|
+
if (notification && !notification.read) {
|
|
300
|
+
notification.read = true;
|
|
301
|
+
notification.readAt = new Date().toISOString();
|
|
302
|
+
writeStatus(status);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 여러 알림을 읽음으로 표시
|
|
311
|
+
*/
|
|
312
|
+
export function markNotificationsAsRead(notificationIds) {
|
|
313
|
+
const status = readStatus();
|
|
314
|
+
let markedCount = 0;
|
|
315
|
+
|
|
316
|
+
notificationIds.forEach(id => {
|
|
317
|
+
const notification = status.notifications.find(n => n.id === id);
|
|
318
|
+
if (notification && !notification.read) {
|
|
319
|
+
notification.read = true;
|
|
320
|
+
notification.readAt = new Date().toISOString();
|
|
321
|
+
markedCount++;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (markedCount > 0) {
|
|
326
|
+
writeStatus(status);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return markedCount;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 특정 조건에 맞는 알림을 읽음으로 표시
|
|
334
|
+
*/
|
|
335
|
+
export function markNotificationsByFilter(filter) {
|
|
336
|
+
const status = readStatus();
|
|
337
|
+
let markedCount = 0;
|
|
338
|
+
|
|
339
|
+
status.notifications.forEach(n => {
|
|
340
|
+
if (!n.read && filter(n)) {
|
|
341
|
+
n.read = true;
|
|
342
|
+
n.readAt = new Date().toISOString();
|
|
343
|
+
markedCount++;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (markedCount > 0) {
|
|
348
|
+
writeStatus(status);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return markedCount;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 파일 잠금 획득
|
|
356
|
+
*/
|
|
357
|
+
export function acquireLock(sessionId, filePath, timeoutMs = 30000) {
|
|
358
|
+
const status = readStatus();
|
|
359
|
+
|
|
360
|
+
// 기존 잠금 확인
|
|
361
|
+
if (status.locks[filePath]) {
|
|
362
|
+
const lock = status.locks[filePath];
|
|
363
|
+
const lockAge = Date.now() - new Date(lock.acquiredAt).getTime();
|
|
364
|
+
|
|
365
|
+
// 타임아웃 초과 시 강제 해제
|
|
366
|
+
if (lockAge > timeoutMs) {
|
|
367
|
+
addNotificationInternal(status, 'warning', 'system',
|
|
368
|
+
`파일 잠금 타임아웃: ${filePath} (${lock.holder})`);
|
|
369
|
+
delete status.locks[filePath];
|
|
370
|
+
} else {
|
|
371
|
+
// 다른 세션이 잠금 보유 중
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 잠금 획득
|
|
377
|
+
status.locks[filePath] = {
|
|
378
|
+
holder: sessionId,
|
|
379
|
+
acquiredAt: new Date().toISOString()
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
writeStatus(status);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 파일 잠금 해제
|
|
388
|
+
*/
|
|
389
|
+
export function releaseLock(sessionId, filePath) {
|
|
390
|
+
const status = readStatus();
|
|
391
|
+
|
|
392
|
+
if (status.locks[filePath] && status.locks[filePath].holder === sessionId) {
|
|
393
|
+
delete status.locks[filePath];
|
|
394
|
+
writeStatus(status);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 읽지 않은 알림 개수
|
|
403
|
+
*/
|
|
404
|
+
export function getUnreadNotificationCount() {
|
|
405
|
+
const status = readStatus();
|
|
406
|
+
return status.notifications.filter(n => !n.read).length;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 활성 세션 목록
|
|
411
|
+
*/
|
|
412
|
+
export function getActiveSessions() {
|
|
413
|
+
const status = readStatus();
|
|
414
|
+
return status.activeSessions;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 대기 중인 질문 목록
|
|
419
|
+
*/
|
|
420
|
+
export function getPendingQuestions() {
|
|
421
|
+
const status = readStatus();
|
|
422
|
+
return status.pendingQuestions.filter(q => q.status === 'waiting');
|
|
423
|
+
}
|
|
424
|
+
|
|
178
425
|
/**
|
|
179
|
-
*
|
|
426
|
+
* 좀비 세션 정리 (프로세스 종료/오래된 세션 제거)
|
|
180
427
|
*/
|
|
181
|
-
export function
|
|
428
|
+
export function cleanupZombieSessions(maxAgeMinutes = 60) {
|
|
182
429
|
const status = readStatus();
|
|
430
|
+
const now = Date.now();
|
|
183
431
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const newQuestion = {
|
|
187
|
-
id: questionId,
|
|
188
|
-
from,
|
|
189
|
-
to,
|
|
190
|
-
question,
|
|
191
|
-
options,
|
|
192
|
-
priority,
|
|
193
|
-
status: 'waiting',
|
|
194
|
-
createdAt: new Date().toISOString()
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
status.pendingQuestions.push(newQuestion);
|
|
432
|
+
const removedSessions = [];
|
|
198
433
|
|
|
199
|
-
|
|
200
|
-
|
|
434
|
+
status.activeSessions = status.activeSessions.filter(session => {
|
|
435
|
+
const pidStatus = isProcessAlive(session.pid);
|
|
436
|
+
if (pidStatus === false) {
|
|
437
|
+
removedSessions.push({ sessionId: session.sessionId, reason: 'process' });
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (pidStatus === true) {
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
201
443
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
444
|
+
const startedAtMs = new Date(session.startedAt).getTime();
|
|
445
|
+
if (Number.isNaN(startedAtMs)) {
|
|
446
|
+
removedSessions.push({ sessionId: session.sessionId, reason: 'time' });
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
205
449
|
|
|
206
|
-
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
450
|
+
const age = now - startedAtMs;
|
|
451
|
+
if (age >= maxAgeMinutes * 60 * 1000) {
|
|
452
|
+
removedSessions.push({ sessionId: session.sessionId, reason: 'time' });
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
211
455
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
question.status = 'answered';
|
|
215
|
-
question.answer = answer;
|
|
216
|
-
question.answeredAt = new Date().toISOString();
|
|
456
|
+
return true;
|
|
457
|
+
});
|
|
217
458
|
|
|
218
|
-
|
|
219
|
-
|
|
459
|
+
if (removedSessions.length > 0) {
|
|
460
|
+
removedSessions.forEach(({ sessionId, reason }) => {
|
|
461
|
+
if (reason === 'process') {
|
|
462
|
+
markSessionFileAsError(sessionId, '프로세스 종료 감지로 정리됨');
|
|
463
|
+
} else {
|
|
464
|
+
markSessionFileAsError(sessionId, '오래된 세션 정리됨');
|
|
465
|
+
}
|
|
466
|
+
});
|
|
220
467
|
|
|
468
|
+
addNotificationInternal(status, 'info', 'system', `좀비 세션 ${removedSessions.length}개 정리됨`);
|
|
221
469
|
writeStatus(status);
|
|
222
470
|
}
|
|
223
471
|
|
|
224
|
-
return
|
|
472
|
+
return removedSessions.length;
|
|
225
473
|
}
|
|
226
474
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const status = readStatus();
|
|
232
|
-
|
|
233
|
-
if (!status.taskProgress[taskId]) {
|
|
234
|
-
status.taskProgress[taskId] = {};
|
|
475
|
+
function isProcessAlive(pid) {
|
|
476
|
+
const parsedPid = typeof pid === 'string' ? Number.parseInt(pid, 10) : pid;
|
|
477
|
+
if (!Number.isFinite(parsedPid) || parsedPid <= 0) {
|
|
478
|
+
return null;
|
|
235
479
|
}
|
|
236
480
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* 알림 추가 (내부용)
|
|
247
|
-
*/
|
|
248
|
-
function addNotificationInternal(status, type, from, message) {
|
|
249
|
-
const notificationId = `N${String(status.notifications.length + 1).padStart(3, '0')}`;
|
|
250
|
-
|
|
251
|
-
status.notifications.push({
|
|
252
|
-
id: notificationId,
|
|
253
|
-
type,
|
|
254
|
-
from,
|
|
255
|
-
message,
|
|
256
|
-
read: false,
|
|
257
|
-
createdAt: new Date().toISOString()
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// 알림은 최근 50개만 유지
|
|
261
|
-
if (status.notifications.length > 50) {
|
|
262
|
-
status.notifications = status.notifications.slice(-50);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* 알림 추가 (외부용)
|
|
268
|
-
*/
|
|
269
|
-
export function addNotification(type, from, message) {
|
|
270
|
-
const status = readStatus();
|
|
271
|
-
addNotificationInternal(status, type, from, message);
|
|
272
|
-
writeStatus(status);
|
|
273
|
-
return status;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* 파일 잠금 획득
|
|
278
|
-
*/
|
|
279
|
-
export function acquireLock(sessionId, filePath, timeoutMs = 30000) {
|
|
280
|
-
const status = readStatus();
|
|
281
|
-
|
|
282
|
-
// 기존 잠금 확인
|
|
283
|
-
if (status.locks[filePath]) {
|
|
284
|
-
const lock = status.locks[filePath];
|
|
285
|
-
const lockAge = Date.now() - new Date(lock.acquiredAt).getTime();
|
|
286
|
-
|
|
287
|
-
// 타임아웃 초과 시 강제 해제
|
|
288
|
-
if (lockAge > timeoutMs) {
|
|
289
|
-
addNotificationInternal(status, 'warning', 'system',
|
|
290
|
-
`파일 잠금 타임아웃: ${filePath} (${lock.holder})`);
|
|
291
|
-
delete status.locks[filePath];
|
|
292
|
-
} else {
|
|
293
|
-
// 다른 세션이 잠금 보유 중
|
|
294
|
-
return false;
|
|
481
|
+
try {
|
|
482
|
+
process.kill(parsedPid, 0);
|
|
483
|
+
return true;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error.code === 'EPERM') {
|
|
486
|
+
return true;
|
|
295
487
|
}
|
|
488
|
+
return false;
|
|
296
489
|
}
|
|
297
|
-
|
|
298
|
-
// 잠금 획득
|
|
299
|
-
status.locks[filePath] = {
|
|
300
|
-
holder: sessionId,
|
|
301
|
-
acquiredAt: new Date().toISOString()
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
writeStatus(status);
|
|
305
|
-
return true;
|
|
306
490
|
}
|
|
307
491
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (status.locks[filePath] && status.locks[filePath].holder === sessionId) {
|
|
315
|
-
delete status.locks[filePath];
|
|
316
|
-
writeStatus(status);
|
|
317
|
-
return true;
|
|
492
|
+
function markSessionFileAsError(sessionId, reason) {
|
|
493
|
+
const sessionsDir = getSessionsDir();
|
|
494
|
+
const sessionFile = path.join(sessionsDir, sessionId, 'session.json');
|
|
495
|
+
if (!fs.existsSync(sessionFile)) {
|
|
496
|
+
return;
|
|
318
497
|
}
|
|
319
498
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
*/
|
|
334
|
-
export function getActiveSessions() {
|
|
335
|
-
const status = readStatus();
|
|
336
|
-
return status.activeSessions;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* 대기 중인 질문 목록
|
|
341
|
-
*/
|
|
342
|
-
export function getPendingQuestions() {
|
|
343
|
-
const status = readStatus();
|
|
344
|
-
return status.pendingQuestions.filter(q => q.status === 'waiting');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* 좀비 세션 정리 (오래된 세션 제거)
|
|
349
|
-
*/
|
|
350
|
-
export function cleanupZombieSessions(maxAgeMinutes = 60) {
|
|
351
|
-
const status = readStatus();
|
|
352
|
-
const now = Date.now();
|
|
353
|
-
|
|
354
|
-
const originalCount = status.activeSessions.length;
|
|
355
|
-
|
|
356
|
-
status.activeSessions = status.activeSessions.filter(session => {
|
|
357
|
-
const age = now - new Date(session.startedAt).getTime();
|
|
358
|
-
return age < maxAgeMinutes * 60 * 1000;
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const removedCount = originalCount - status.activeSessions.length;
|
|
362
|
-
|
|
363
|
-
if (removedCount > 0) {
|
|
364
|
-
addNotificationInternal(status, 'info', 'system', `좀비 세션 ${removedCount}개 정리됨`);
|
|
365
|
-
writeStatus(status);
|
|
499
|
+
try {
|
|
500
|
+
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
501
|
+
if (session.status && session.status !== 'active') {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
session.status = 'error';
|
|
505
|
+
session.error = reason;
|
|
506
|
+
if (!session.ended_at) {
|
|
507
|
+
session.ended_at = getTimestamp();
|
|
508
|
+
}
|
|
509
|
+
fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2));
|
|
510
|
+
} catch (error) {
|
|
511
|
+
// 세션 파일 오류는 무시
|
|
366
512
|
}
|
|
367
|
-
|
|
368
|
-
return removedCount;
|
|
369
513
|
}
|