harness-bujang 0.1.0
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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.js +906 -0
- package/package.json +57 -0
- package/templates/agents/en/architect-team.md +105 -0
- package/templates/agents/en/code-review-team.md +106 -0
- package/templates/agents/en/consultant.md +106 -0
- package/templates/agents/en/db-guard-team.md +94 -0
- package/templates/agents/en/dev-team.md +143 -0
- package/templates/agents/en/director.md +401 -0
- package/templates/agents/en/doc-sync-team.md +92 -0
- package/templates/agents/en/qa-team.md +100 -0
- package/templates/agents/en/security-team.md +99 -0
- package/templates/agents/en/verifier-team.md +97 -0
- package/templates/agents/ko/architect-team.md +110 -0
- package/templates/agents/ko/code-review-team.md +124 -0
- package/templates/agents/ko/consultant.md +106 -0
- package/templates/agents/ko/db-guard-team.md +116 -0
- package/templates/agents/ko/dev-team.md +144 -0
- package/templates/agents/ko/director.md +401 -0
- package/templates/agents/ko/doc-sync-team.md +114 -0
- package/templates/agents/ko/qa-team.md +122 -0
- package/templates/agents/ko/security-team.md +121 -0
- package/templates/agents/ko/verifier-team.md +119 -0
- package/templates/project-template/app/admin/harness/harness-client.tsx +493 -0
- package/templates/project-template/app/admin/harness/page.tsx +27 -0
- package/templates/project-template/app/api/harness/logs/route.ts +111 -0
- package/templates/project-template/app/api/harness/reply/route.ts +45 -0
- package/templates/project-template/lib/harness-db/index.ts +45 -0
- package/templates/project-template/lib/harness-db/sqlite.ts +128 -0
- package/templates/project-template/lib/harness-db/supabase.ts +64 -0
- package/templates/project-template/lib/harness-db/types.ts +35 -0
- package/templates/project-template/migrations/00010_harness_messages.sql +56 -0
- package/templates/project-template/migrations/00025_harness_insert_admin_only.sql +17 -0
- package/templates/templates/en/AGENT_LEARNING_LOG.seed.md +43 -0
- package/templates/templates/en/CLAUDE.md.harness-section.template +59 -0
- package/templates/templates/ko/AGENT_LEARNING_LOG.seed.md +43 -0
- package/templates/templates/ko/CLAUDE.md.harness-section.template +59 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
interface ChatMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
type: 'command' | 'report' | 'feedback' | 'info';
|
|
15
|
+
message: string;
|
|
16
|
+
severity?: 'error' | 'warning' | 'info';
|
|
17
|
+
data?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Roles & Rooms
|
|
22
|
+
//
|
|
23
|
+
// Edit these constants to match your harness configuration.
|
|
24
|
+
// Role names must match what the Director / teams use as `from` / `to` in
|
|
25
|
+
// chat-room INSERTs.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const ROLES: Record<string, { icon: string; color: string; bg: string; label?: string }> = {
|
|
29
|
+
'λνλ': { icon: 'π', color: 'text-purple-700', bg: 'bg-purple-100' },
|
|
30
|
+
'λΆμ₯': { icon: 'π§βπΌ', color: 'text-blue-700', bg: 'bg-blue-100', label: 'λΆμ₯' },
|
|
31
|
+
'consultant': { icon: 'π€', color: 'text-indigo-700', bg: 'bg-indigo-100', label: '컨μ€ν΄νΈ' },
|
|
32
|
+
'dev-team': { icon: 'π»', color: 'text-violet-700', bg: 'bg-violet-100', label: 'κ°λ°ν' },
|
|
33
|
+
'architect-team': { icon: 'ποΈ', color: 'text-cyan-700', bg: 'bg-cyan-100', label: 'μν€ν
μ²ν' },
|
|
34
|
+
'code-review-team': { icon: 'π', color: 'text-yellow-700', bg: 'bg-yellow-100', label: 'μ½λ리뷰ν' },
|
|
35
|
+
'doc-sync-team': { icon: 'π', color: 'text-orange-700', bg: 'bg-orange-100', label: 'λ¬Έμκ΄λ¦¬ν' },
|
|
36
|
+
'security-team': { icon: 'π‘οΈ', color: 'text-red-700', bg: 'bg-red-100', label: '보μν' },
|
|
37
|
+
'db-guard-team': { icon: 'ποΈ', color: 'text-green-700', bg: 'bg-green-100', label: 'DBν' },
|
|
38
|
+
'qa-team': { icon: 'π§ͺ', color: 'text-teal-700', bg: 'bg-teal-100', label: 'QAν' },
|
|
39
|
+
'verifier-team': { icon: 'β
', color: 'text-emerald-700', bg: 'bg-emerald-100', label: 'κ²μν' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ROOMS = [
|
|
43
|
+
{ id: 'λνλ', name: 'λν λ³΄κ³ ', icon: 'π', members: ['λνλ', 'consultant', 'λΆμ₯'] },
|
|
44
|
+
{ id: 'consultant', name: '컨μ€ν΄νΈ', icon: 'π€', members: ['consultant', 'λΆμ₯'] },
|
|
45
|
+
{ id: 'architect-team', name: 'μν€ν
μ²ν', icon: 'ποΈ', members: ['λΆμ₯', 'architect-team'] },
|
|
46
|
+
{ id: 'code-review-team', name: 'μ½λ리뷰ν', icon: 'π', members: ['λΆμ₯', 'code-review-team'] },
|
|
47
|
+
{ id: 'doc-sync-team', name: 'λ¬Έμκ΄λ¦¬ν', icon: 'π', members: ['λΆμ₯', 'doc-sync-team'] },
|
|
48
|
+
{ id: 'security-team', name: '보μν', icon: 'π‘οΈ', members: ['λΆμ₯', 'security-team'] },
|
|
49
|
+
{ id: 'db-guard-team', name: 'DBν', icon: 'ποΈ', members: ['λΆμ₯', 'db-guard-team'] },
|
|
50
|
+
{ id: 'qa-team', name: 'QAν', icon: 'π§ͺ', members: ['λΆμ₯', 'qa-team'] },
|
|
51
|
+
{ id: 'verifier-team', name: 'κ²μν', icon: 'β
', members: ['λΆμ₯', 'verifier-team'] },
|
|
52
|
+
{ id: 'dev-team', name: 'κ°λ°ν', icon: 'π»', members: ['λΆμ₯', 'dev-team'] },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const STORAGE_KEY = 'harness-bujang-read';
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function getRole(name: string) {
|
|
62
|
+
return ROLES[name] || { icon: 'π¬', color: 'text-gray-700', bg: 'bg-gray-100' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getRoleLabel(name: string) {
|
|
66
|
+
return ROLES[name]?.label ?? name;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatTime(timestamp: string) {
|
|
70
|
+
const d = new Date(timestamp);
|
|
71
|
+
const h = d.getHours();
|
|
72
|
+
const m = d.getMinutes().toString().padStart(2, '0');
|
|
73
|
+
const ampm = h < 12 ? 'μ€μ ' : 'μ€ν';
|
|
74
|
+
const hour = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
75
|
+
return `${ampm} ${hour}:${m}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatDate(timestamp: string) {
|
|
79
|
+
const d = new Date(timestamp);
|
|
80
|
+
return `${d.getFullYear()}λ
${d.getMonth() + 1}μ ${d.getDate()}μΌ`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterMessages(messages: ChatMessage[], roomId: string): ChatMessage[] {
|
|
84
|
+
if (roomId === 'all') return messages;
|
|
85
|
+
|
|
86
|
+
const room = ROOMS.find((r) => r.id === roomId);
|
|
87
|
+
if (!room) return [];
|
|
88
|
+
|
|
89
|
+
return messages.filter((m) => {
|
|
90
|
+
if (!room.members.includes(m.from) || !room.members.includes(m.to)) return false;
|
|
91
|
+
|
|
92
|
+
// Prefer the more specific (smaller-membered) room when multiple match.
|
|
93
|
+
const smallerRoom = ROOMS.find(
|
|
94
|
+
(r) =>
|
|
95
|
+
r.id !== roomId &&
|
|
96
|
+
r.members.length < room.members.length &&
|
|
97
|
+
r.members.includes(m.from) &&
|
|
98
|
+
r.members.includes(m.to),
|
|
99
|
+
);
|
|
100
|
+
return !smallerRoom;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getLastMessage(messages: ChatMessage[], roomId: string): ChatMessage | null {
|
|
105
|
+
const filtered = filterMessages(messages, roomId);
|
|
106
|
+
return filtered.length > 0 ? filtered[filtered.length - 1] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getSeverityBadge(severity?: string) {
|
|
110
|
+
switch (severity) {
|
|
111
|
+
case 'error': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded">ERROR</span>;
|
|
112
|
+
case 'warning': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500 text-white rounded">WARN</span>;
|
|
113
|
+
case 'info': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-green-500 text-white rounded">INFO</span>;
|
|
114
|
+
default: return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Component
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export function HarnessClient() {
|
|
123
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
124
|
+
const [loading, setLoading] = useState(true);
|
|
125
|
+
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
|
|
126
|
+
const [readCounts, setReadCounts] = useState<Record<string, number>>({});
|
|
127
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
128
|
+
const [hasOlder, setHasOlder] = useState(true);
|
|
129
|
+
const [inputText, setInputText] = useState('');
|
|
130
|
+
const [sending, setSending] = useState(false);
|
|
131
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
const chatContainerRef = useRef<HTMLDivElement>(null);
|
|
133
|
+
const isNearBottomRef = useRef(true);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
137
|
+
if (stored) setReadCounts(JSON.parse(stored));
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
// Polling: load last 2 days, refresh every 2 seconds.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
function fetchMessages() {
|
|
143
|
+
fetch('/api/harness/logs?days=2')
|
|
144
|
+
.then((res) => res.json())
|
|
145
|
+
.then((res) => {
|
|
146
|
+
const fresh: ChatMessage[] = res.data || [];
|
|
147
|
+
setMessages((prev) => {
|
|
148
|
+
if (prev.length === 0) return fresh;
|
|
149
|
+
const oldestFreshTs = fresh.length > 0 ? fresh[0].timestamp : '';
|
|
150
|
+
const olderMessages = prev.filter((m) => m.timestamp < oldestFreshTs);
|
|
151
|
+
return [...olderMessages, ...fresh];
|
|
152
|
+
});
|
|
153
|
+
setLoading(false);
|
|
154
|
+
})
|
|
155
|
+
.catch(() => setLoading(false));
|
|
156
|
+
}
|
|
157
|
+
fetchMessages();
|
|
158
|
+
const interval = setInterval(fetchMessages, 2000);
|
|
159
|
+
return () => clearInterval(interval);
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
async function loadOlderMessages() {
|
|
163
|
+
if (loadingOlder || !hasOlder || messages.length === 0) return;
|
|
164
|
+
setLoadingOlder(true);
|
|
165
|
+
const oldestTs = messages[0].timestamp;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(`/api/harness/logs?before=${encodeURIComponent(oldestTs)}&limit=50`);
|
|
168
|
+
const data = await res.json();
|
|
169
|
+
const older: ChatMessage[] = data.data || [];
|
|
170
|
+
if (older.length === 0) {
|
|
171
|
+
setHasOlder(false);
|
|
172
|
+
} else {
|
|
173
|
+
const el = chatContainerRef.current;
|
|
174
|
+
const prevHeight = el?.scrollHeight || 0;
|
|
175
|
+
setMessages((prev) => [...older, ...prev]);
|
|
176
|
+
requestAnimationFrame(() => {
|
|
177
|
+
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} catch { /* ignore */ }
|
|
181
|
+
setLoadingOlder(false);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const el = chatContainerRef.current;
|
|
186
|
+
if (!el) return;
|
|
187
|
+
const handleScroll = () => {
|
|
188
|
+
const threshold = 80;
|
|
189
|
+
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
190
|
+
if (el.scrollTop < 50 && hasOlder && !loadingOlder) {
|
|
191
|
+
loadOlderMessages();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
el.addEventListener('scroll', handleScroll);
|
|
195
|
+
return () => el.removeEventListener('scroll', handleScroll);
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
}, [selectedRoom, hasOlder, loadingOlder, messages]);
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (isNearBottomRef.current) {
|
|
201
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
202
|
+
}
|
|
203
|
+
}, [messages]);
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (selectedRoom) {
|
|
207
|
+
isNearBottomRef.current = true;
|
|
208
|
+
setHasOlder(true);
|
|
209
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'instant' });
|
|
210
|
+
}
|
|
211
|
+
}, [selectedRoom]);
|
|
212
|
+
|
|
213
|
+
function enterRoom(roomId: string) {
|
|
214
|
+
setSelectedRoom(roomId);
|
|
215
|
+
const count = filterMessages(messages, roomId).length;
|
|
216
|
+
const newCounts = { ...readCounts, [roomId]: count };
|
|
217
|
+
setReadCounts(newCounts);
|
|
218
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCounts));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const roomMessages = selectedRoom ? filterMessages(messages, selectedRoom) : [];
|
|
222
|
+
const selectedRoomInfo = ROOMS.find((r) => r.id === selectedRoom);
|
|
223
|
+
|
|
224
|
+
const dateGroups: { date: string; messages: ChatMessage[] }[] = [];
|
|
225
|
+
let lastDate = '';
|
|
226
|
+
for (const msg of roomMessages) {
|
|
227
|
+
const date = formatDate(msg.timestamp);
|
|
228
|
+
if (date !== lastDate) {
|
|
229
|
+
dateGroups.push({ date, messages: [msg] });
|
|
230
|
+
lastDate = date;
|
|
231
|
+
} else {
|
|
232
|
+
dateGroups[dateGroups.length - 1].messages.push(msg);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const errors = messages.filter((m) => m.severity === 'error').length;
|
|
237
|
+
const warnings = messages.filter((m) => m.severity === 'warning').length;
|
|
238
|
+
const infos = messages.filter((m) => m.severity === 'info').length;
|
|
239
|
+
|
|
240
|
+
if (loading) {
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex items-center justify-center h-[calc(100vh-4rem)] text-gray-400">
|
|
243
|
+
λΆλ¬μ€λ μ€...
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="flex h-[calc(100vh-4rem)]">
|
|
250
|
+
{/* Sidebar β room list */}
|
|
251
|
+
<div className="w-80 border-r border-gray-200 bg-white flex flex-col">
|
|
252
|
+
<div className="p-4 border-b border-gray-200">
|
|
253
|
+
<h1 className="text-lg font-bold text-gray-900">νλ€μ€ ν‘λ°©</h1>
|
|
254
|
+
<p className="text-xs text-gray-500 mt-1">μμ΄μ νΈ κ° λ³΄κ³ & μ§μ</p>
|
|
255
|
+
|
|
256
|
+
{messages.length > 0 && (
|
|
257
|
+
<div className="flex gap-2 mt-2">
|
|
258
|
+
{errors > 0 && (
|
|
259
|
+
<span className="px-2 py-0.5 text-xs font-bold bg-red-100 text-red-700 rounded-full">
|
|
260
|
+
ERR {errors}
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
263
|
+
{warnings > 0 && (
|
|
264
|
+
<span className="px-2 py-0.5 text-xs font-bold bg-yellow-100 text-yellow-700 rounded-full">
|
|
265
|
+
WARN {warnings}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
{infos > 0 && (
|
|
269
|
+
<span className="px-2 py-0.5 text-xs font-bold bg-green-100 text-green-700 rounded-full">
|
|
270
|
+
INFO {infos}
|
|
271
|
+
</span>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className="flex-1 overflow-y-auto">
|
|
278
|
+
{ROOMS.map((room) => {
|
|
279
|
+
const last = getLastMessage(messages, room.id);
|
|
280
|
+
const count = filterMessages(messages, room.id).length;
|
|
281
|
+
const isSelected = selectedRoom === room.id;
|
|
282
|
+
const unread = count - (readCounts[room.id] || 0);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<button
|
|
286
|
+
key={room.id}
|
|
287
|
+
onDoubleClick={() => enterRoom(room.id)}
|
|
288
|
+
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
|
|
289
|
+
isSelected ? 'bg-indigo-50' : 'hover:bg-gray-50'
|
|
290
|
+
}`}
|
|
291
|
+
>
|
|
292
|
+
<div className="flex-shrink-0 w-12 h-12 rounded-2xl bg-gray-100 flex items-center justify-center text-2xl">
|
|
293
|
+
{room.icon}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div className="flex-1 min-w-0">
|
|
297
|
+
<div className="flex items-center justify-between">
|
|
298
|
+
<span className="text-sm font-semibold text-gray-900 truncate">
|
|
299
|
+
{room.name}
|
|
300
|
+
<span className="text-xs text-gray-400 font-normal ml-1">
|
|
301
|
+
{room.members.length}
|
|
302
|
+
</span>
|
|
303
|
+
</span>
|
|
304
|
+
{last && (
|
|
305
|
+
<span className="text-xs text-gray-400 flex-shrink-0 ml-2">
|
|
306
|
+
{formatTime(last.timestamp)}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
311
|
+
{last?.severity && getSeverityBadge(last.severity)}
|
|
312
|
+
<p className="text-xs text-gray-500 truncate">
|
|
313
|
+
{last ? last.message : 'λν μμ'}
|
|
314
|
+
</p>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{unread > 0 && (
|
|
319
|
+
<span className="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
|
320
|
+
{unread}
|
|
321
|
+
</span>
|
|
322
|
+
)}
|
|
323
|
+
</button>
|
|
324
|
+
);
|
|
325
|
+
})}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
{/* Right pane β conversation */}
|
|
330
|
+
<div className="flex-1 flex flex-col bg-[#b2c7d9]">
|
|
331
|
+
{!selectedRoom ? (
|
|
332
|
+
<div className="flex-1 flex items-center justify-center">
|
|
333
|
+
<div className="text-center">
|
|
334
|
+
<p className="text-5xl mb-3">π’</p>
|
|
335
|
+
<p className="text-sm text-white/70">μ±ν
λ°©μ λλΈν΄λ¦ν΄μ μ΄μ΄μ£ΌμΈμ</p>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
) : (
|
|
339
|
+
<>
|
|
340
|
+
<div className="px-5 py-3 bg-white border-b border-gray-200 flex items-center gap-3">
|
|
341
|
+
<span className="text-xl">{selectedRoomInfo?.icon}</span>
|
|
342
|
+
<div>
|
|
343
|
+
<h2 className="text-sm font-semibold text-gray-900">
|
|
344
|
+
{selectedRoomInfo?.name}
|
|
345
|
+
</h2>
|
|
346
|
+
<p className="text-xs text-gray-400">
|
|
347
|
+
{selectedRoomInfo?.members.map(getRoleLabel).join(', ')}
|
|
348
|
+
</p>
|
|
349
|
+
</div>
|
|
350
|
+
<span className="ml-auto text-xs text-gray-400">
|
|
351
|
+
{roomMessages.length}κ° λ©μμ§
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div ref={chatContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
|
356
|
+
{roomMessages.length === 0 ? (
|
|
357
|
+
<div className="flex items-center justify-center h-full">
|
|
358
|
+
<div className="text-center">
|
|
359
|
+
<p className="text-4xl mb-2">π¬</p>
|
|
360
|
+
<p className="text-sm text-white/70">μμ§ λνκ° μμ΅λλ€</p>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
) : (
|
|
364
|
+
<div className="space-y-1">
|
|
365
|
+
{loadingOlder && (
|
|
366
|
+
<div className="flex justify-center py-3">
|
|
367
|
+
<span className="text-xs text-white/60">μ΄μ λν λΆλ¬μ€λ μ€...</span>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
{!hasOlder && messages.length > 0 && (
|
|
371
|
+
<div className="flex justify-center py-3">
|
|
372
|
+
<span className="text-xs text-white/40">λ μ΄μ μ΄μ λνκ° μμ΅λλ€</span>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
{dateGroups.map((group) => (
|
|
376
|
+
<div key={group.date}>
|
|
377
|
+
<div className="flex items-center justify-center my-4">
|
|
378
|
+
<span className="bg-black/20 text-white text-xs px-3 py-1 rounded-full">
|
|
379
|
+
{group.date}
|
|
380
|
+
</span>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{group.messages.map((msg, i) => {
|
|
384
|
+
const role = getRole(msg.from);
|
|
385
|
+
const prevMsg = i > 0 ? group.messages[i - 1] : null;
|
|
386
|
+
const isSameAuthor = prevMsg?.from === msg.from;
|
|
387
|
+
const isMe = msg.from === 'λνλ';
|
|
388
|
+
|
|
389
|
+
if (isMe) {
|
|
390
|
+
return (
|
|
391
|
+
<div key={msg.id} className={`flex justify-end ${isSameAuthor ? 'mt-0.5' : 'mt-3'}`}>
|
|
392
|
+
<div className="flex items-end gap-1.5 max-w-[70%]">
|
|
393
|
+
<span className="text-xs text-gray-500/70 flex-shrink-0 pb-0.5">
|
|
394
|
+
{formatTime(msg.timestamp)}
|
|
395
|
+
</span>
|
|
396
|
+
<div className="bg-[#fee500] rounded-xl px-3 py-2 shadow-sm">
|
|
397
|
+
<p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
|
|
398
|
+
{msg.message}
|
|
399
|
+
</p>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div key={msg.id} className={`flex gap-2 ${isSameAuthor ? 'mt-0.5' : 'mt-3'}`}>
|
|
408
|
+
{!isSameAuthor ? (
|
|
409
|
+
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${role.bg} flex items-center justify-center text-lg`}>
|
|
410
|
+
{role.icon}
|
|
411
|
+
</div>
|
|
412
|
+
) : (
|
|
413
|
+
<div className="w-10 flex-shrink-0" />
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
<div className="flex-1 min-w-0">
|
|
417
|
+
{!isSameAuthor && (
|
|
418
|
+
<p className={`text-xs font-semibold mb-1 ${role.color}`}>
|
|
419
|
+
{getRoleLabel(msg.from)}
|
|
420
|
+
</p>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
<div className="flex items-end gap-1.5">
|
|
424
|
+
<div className="max-w-[70%]">
|
|
425
|
+
<div className="bg-white rounded-xl px-3 py-2 shadow-sm">
|
|
426
|
+
{msg.severity && (
|
|
427
|
+
<div className="mb-1">{getSeverityBadge(msg.severity)}</div>
|
|
428
|
+
)}
|
|
429
|
+
<p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
|
|
430
|
+
{msg.message}
|
|
431
|
+
</p>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<span className="text-xs text-gray-500/70 flex-shrink-0 pb-0.5">
|
|
436
|
+
{formatTime(msg.timestamp)}
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
})}
|
|
443
|
+
</div>
|
|
444
|
+
))}
|
|
445
|
+
<div ref={messagesEndRef} />
|
|
446
|
+
</div>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div className="px-4 py-3 bg-white border-t border-gray-200">
|
|
451
|
+
<form
|
|
452
|
+
onSubmit={async (e) => {
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
if (!inputText.trim() || sending || !selectedRoom) return;
|
|
455
|
+
setSending(true);
|
|
456
|
+
try {
|
|
457
|
+
await fetch('/api/harness/reply', {
|
|
458
|
+
method: 'POST',
|
|
459
|
+
headers: { 'Content-Type': 'application/json' },
|
|
460
|
+
body: JSON.stringify({ message: inputText.trim(), room: selectedRoom }),
|
|
461
|
+
});
|
|
462
|
+
setInputText('');
|
|
463
|
+
isNearBottomRef.current = true;
|
|
464
|
+
} catch { /* ignore */ }
|
|
465
|
+
setSending(false);
|
|
466
|
+
}}
|
|
467
|
+
className="flex items-center gap-2"
|
|
468
|
+
>
|
|
469
|
+
<input
|
|
470
|
+
type="text"
|
|
471
|
+
value={inputText}
|
|
472
|
+
onChange={(e) => setInputText(e.target.value)}
|
|
473
|
+
placeholder="λνλμΌλ‘ λ©μμ§ λ³΄λ΄κΈ°..."
|
|
474
|
+
disabled={sending}
|
|
475
|
+
className="flex-1 px-4 py-2.5 bg-gray-100 rounded-full text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-300 disabled:opacity-50"
|
|
476
|
+
/>
|
|
477
|
+
<button
|
|
478
|
+
type="submit"
|
|
479
|
+
disabled={!inputText.trim() || sending}
|
|
480
|
+
className="flex-shrink-0 w-10 h-10 bg-indigo-500 text-white rounded-full flex items-center justify-center hover:bg-indigo-600 disabled:opacity-30 disabled:hover:bg-indigo-500 transition-colors"
|
|
481
|
+
>
|
|
482
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
483
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
|
484
|
+
</svg>
|
|
485
|
+
</button>
|
|
486
|
+
</form>
|
|
487
|
+
</div>
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { verifySuperAdmin } from '@/lib/utils/admin';
|
|
3
|
+
import { HarnessClient } from './harness-client';
|
|
4
|
+
import type { Metadata } from 'next';
|
|
5
|
+
|
|
6
|
+
// Harness-Bujang chat-room admin page.
|
|
7
|
+
//
|
|
8
|
+
// Assumptions:
|
|
9
|
+
// - Next.js 16 App Router
|
|
10
|
+
// - A `verifySuperAdmin()` helper exists at `@/lib/utils/admin` and returns
|
|
11
|
+
// `{ isSuperAdmin: boolean, supabase?: SupabaseClient }` based on the
|
|
12
|
+
// authenticated user being in `SUPER_ADMIN_EMAILS` (or your own scheme).
|
|
13
|
+
// - The `harness_messages` table exists (see migrations).
|
|
14
|
+
//
|
|
15
|
+
// Adjust the import / redirect target to match your project's admin layout.
|
|
16
|
+
|
|
17
|
+
export const metadata: Metadata = { title: 'νλ€μ€ ν‘λ°©' };
|
|
18
|
+
|
|
19
|
+
export default async function HarnessPage() {
|
|
20
|
+
const { isSuperAdmin } = await verifySuperAdmin();
|
|
21
|
+
|
|
22
|
+
if (!isSuperAdmin) {
|
|
23
|
+
redirect('/admin');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return <HarnessClient />;
|
|
27
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { verifySuperAdmin } from '@/lib/utils/admin';
|
|
3
|
+
import { getHarnessDb, type ChatMessage } from '@/lib/harness-db';
|
|
4
|
+
|
|
5
|
+
// GET /api/harness/logs?days=2 β recent N days
|
|
6
|
+
// GET /api/harness/logs?before=<iso>&limit=50 β infinite scroll (older)
|
|
7
|
+
// POST /api/harness/logs β INSERT a chat message
|
|
8
|
+
//
|
|
9
|
+
// Two callers are supported on POST:
|
|
10
|
+
// 1. A super-admin browser session (verifySuperAdmin): used by the chat-room
|
|
11
|
+
// UI when the principal types a message. The server forces `from='λνλ'`.
|
|
12
|
+
// 2. A bot / script with `x-harness-secret` header (HARNESS_WRITE_SECRET):
|
|
13
|
+
// lets agents (Director, dev-team, etc.) post under their own role names.
|
|
14
|
+
// `from='λνλ'` is rejected on this path to prevent spoofing.
|
|
15
|
+
//
|
|
16
|
+
// The active database (sqlite | supabase) is decided by the HARNESS_DB env var
|
|
17
|
+
// β see `lib/harness-db/index.ts`.
|
|
18
|
+
|
|
19
|
+
export async function GET(request: NextRequest) {
|
|
20
|
+
try {
|
|
21
|
+
const { isSuperAdmin } = await verifySuperAdmin();
|
|
22
|
+
if (!isSuperAdmin) {
|
|
23
|
+
return NextResponse.json({ data: [], error: 'super-admin only' }, { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { searchParams } = new URL(request.url);
|
|
27
|
+
const days = parseInt(searchParams.get('days') ?? '2', 10);
|
|
28
|
+
const before = searchParams.get('before') ?? undefined;
|
|
29
|
+
const limit = parseInt(searchParams.get('limit') ?? '50', 10);
|
|
30
|
+
|
|
31
|
+
const db = getHarnessDb();
|
|
32
|
+
const messages = before ? await db.list({ before, limit }) : await db.list({ days });
|
|
33
|
+
|
|
34
|
+
return NextResponse.json({ data: messages });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ data: [], error: err instanceof Error ? err.message : 'unknown' },
|
|
38
|
+
{ status: 500 },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function POST(request: NextRequest) {
|
|
44
|
+
try {
|
|
45
|
+
const secret = request.headers.get('x-harness-secret');
|
|
46
|
+
const hasSecret =
|
|
47
|
+
!!process.env.HARNESS_WRITE_SECRET &&
|
|
48
|
+
secret === process.env.HARNESS_WRITE_SECRET;
|
|
49
|
+
|
|
50
|
+
let isSuperAdminSession = false;
|
|
51
|
+
if (!hasSecret) {
|
|
52
|
+
const { isSuperAdmin } = await verifySuperAdmin();
|
|
53
|
+
if (!isSuperAdmin) {
|
|
54
|
+
return NextResponse.json({ error: 'super-admin only' }, { status: 403 });
|
|
55
|
+
}
|
|
56
|
+
isSuperAdminSession = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body = await request.json();
|
|
60
|
+
const { id, from: rawFrom, to, type, message, severity, data, timestamp } = body;
|
|
61
|
+
|
|
62
|
+
if (!to || !message) {
|
|
63
|
+
return NextResponse.json({ error: 'to and message are required' }, { status: 400 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let from: string;
|
|
67
|
+
if (isSuperAdminSession) {
|
|
68
|
+
from = 'λνλ';
|
|
69
|
+
} else {
|
|
70
|
+
if (!rawFrom || typeof rawFrom !== 'string') {
|
|
71
|
+
return NextResponse.json({ error: 'from is required' }, { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
const trimmed = rawFrom.trim();
|
|
74
|
+
const compact = trimmed.replace(/[\sβγ]+/g, '');
|
|
75
|
+
if (compact === 'λνλ') {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: 'secret path may not use from="λνλ" (super-admin session only)' },
|
|
78
|
+
{ status: 403 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
from = trimmed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const db = getHarnessDb();
|
|
85
|
+
|
|
86
|
+
let msgId = id;
|
|
87
|
+
if (!msgId) {
|
|
88
|
+
const total = await db.count();
|
|
89
|
+
msgId = `harness-${String(total + 1).padStart(4, '0')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const msg: ChatMessage = {
|
|
93
|
+
id: msgId,
|
|
94
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
95
|
+
from,
|
|
96
|
+
to,
|
|
97
|
+
type: type || 'report',
|
|
98
|
+
message,
|
|
99
|
+
...(severity ? { severity } : {}),
|
|
100
|
+
...(data ? { data } : {}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await db.upsert(msg);
|
|
104
|
+
return NextResponse.json({ data: msg });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return NextResponse.json(
|
|
107
|
+
{ error: err instanceof Error ? err.message : 'failed to store message' },
|
|
108
|
+
{ status: 500 },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { verifySuperAdmin } from '@/lib/utils/admin';
|
|
3
|
+
import { getHarnessDb, type ChatMessage } from '@/lib/harness-db';
|
|
4
|
+
|
|
5
|
+
// POST /api/harness/reply
|
|
6
|
+
//
|
|
7
|
+
// Stores a message from the principal (super-admin session) into the chat room.
|
|
8
|
+
// The server forces `from='λνλ'` to prevent spoofing.
|
|
9
|
+
//
|
|
10
|
+
// The active database (sqlite | supabase) is decided by the HARNESS_DB env var
|
|
11
|
+
// β see `lib/harness-db/index.ts`.
|
|
12
|
+
|
|
13
|
+
export async function POST(request: NextRequest) {
|
|
14
|
+
try {
|
|
15
|
+
const { isSuperAdmin } = await verifySuperAdmin();
|
|
16
|
+
if (!isSuperAdmin) {
|
|
17
|
+
return NextResponse.json({ error: 'super-admin only' }, { status: 403 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { message, room } = await request.json();
|
|
21
|
+
if (!message) {
|
|
22
|
+
return NextResponse.json({ error: 'message is required' }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const db = getHarnessDb();
|
|
26
|
+
const total = await db.count();
|
|
27
|
+
|
|
28
|
+
const msg: ChatMessage = {
|
|
29
|
+
id: `harness-${String(total + 1).padStart(4, '0')}`,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
from: 'λνλ',
|
|
32
|
+
to: room || 'λΆμ₯',
|
|
33
|
+
type: 'command',
|
|
34
|
+
message,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
await db.upsert(msg);
|
|
38
|
+
return NextResponse.json({ data: { sent: msg } });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: err instanceof Error ? err.message : 'failed to send message' },
|
|
42
|
+
{ status: 500 },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|