@triflux/remote 10.0.0-alpha.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/hub/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// hub/team/nativeProxy.mjs
|
|
2
|
+
// Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import {
|
|
13
|
+
open as openFile,
|
|
14
|
+
readdir,
|
|
15
|
+
readFile,
|
|
16
|
+
stat,
|
|
17
|
+
unlink as unlinkFile,
|
|
18
|
+
} from 'node:fs/promises';
|
|
19
|
+
import { basename, dirname, join } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { isPidAlive } from '../lib/process-utils.mjs';
|
|
23
|
+
import { IS_WINDOWS } from '../platform.mjs';
|
|
24
|
+
|
|
25
|
+
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
|
+
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
27
|
+
const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
|
|
28
|
+
const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
29
|
+
const LOCK_STALE_MS = 30000;
|
|
30
|
+
|
|
31
|
+
// ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
|
|
32
|
+
const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
|
|
33
|
+
const _taskIdIndex = new Map(); // taskId → filePath
|
|
34
|
+
const _taskContentCache = new Map(); // filePath → { mtimeMs, data }
|
|
35
|
+
|
|
36
|
+
function _invalidateCache(tasksDir) {
|
|
37
|
+
_dirCache.delete(tasksDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function err(code, message, extra = {}) {
|
|
41
|
+
return { ok: false, error: { code, message, ...extra } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateTeamName(teamName) {
|
|
45
|
+
if (!TEAM_NAME_RE.test(String(teamName || ''))) {
|
|
46
|
+
throw new Error('INVALID_TEAM_NAME');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonSafe(path) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function atomicWriteJson(path, value) {
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
60
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
61
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
62
|
+
try {
|
|
63
|
+
renameSync(tmp, path);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
|
|
66
|
+
if (IS_WINDOWS && (e.code === 'EPERM' || e.code === 'EEXIST')) {
|
|
67
|
+
try { unlinkSync(path); } catch {}
|
|
68
|
+
renameSync(tmp, path);
|
|
69
|
+
} else {
|
|
70
|
+
try { unlinkSync(tmp); } catch {}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function sleepMs(ms) {
|
|
77
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readLockInfo(lockPath) {
|
|
81
|
+
let lockStat;
|
|
82
|
+
try {
|
|
83
|
+
lockStat = await stat(lockPath);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let parsed = null;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
91
|
+
} catch {}
|
|
92
|
+
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const createdAtMs = Number(
|
|
95
|
+
parsed?.created_at_ms
|
|
96
|
+
?? parsed?.timestamp_ms
|
|
97
|
+
?? parsed?.timestamp
|
|
98
|
+
?? lockStat.mtimeMs,
|
|
99
|
+
);
|
|
100
|
+
const pid = Number(parsed?.pid);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
token: typeof parsed?.token === 'string' ? parsed.token : null,
|
|
104
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
105
|
+
created_at_ms: Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs,
|
|
106
|
+
mtime_ms: lockStat.mtimeMs,
|
|
107
|
+
age_ms: Math.max(0, now - (Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function releaseFileLock(lockPath, token, handle) {
|
|
112
|
+
try { await handle?.close(); } catch {}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const current = await readLockInfo(lockPath);
|
|
116
|
+
if (!current || current.token === token) {
|
|
117
|
+
await unlinkFile(lockPath);
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function withFileLock(lockPath, fn, retries = 20, delayMs = 25, staleMs = LOCK_STALE_MS) {
|
|
123
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
124
|
+
const lockOwner = {
|
|
125
|
+
pid: process.pid,
|
|
126
|
+
token: randomUUID(),
|
|
127
|
+
created_at: new Date().toISOString(),
|
|
128
|
+
created_at_ms: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
let handle = null;
|
|
131
|
+
let lastError = null;
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < retries; i += 1) {
|
|
134
|
+
try {
|
|
135
|
+
handle = await openFile(lockPath, 'wx');
|
|
136
|
+
try {
|
|
137
|
+
await handle.writeFile(`${JSON.stringify(lockOwner)}\n`, 'utf8');
|
|
138
|
+
} catch (writeError) {
|
|
139
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
140
|
+
throw writeError;
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
lastError = e;
|
|
145
|
+
if (e?.code !== 'EEXIST') throw e;
|
|
146
|
+
|
|
147
|
+
const current = await readLockInfo(lockPath);
|
|
148
|
+
const staleByAge = !current || current.age_ms > staleMs;
|
|
149
|
+
const staleByDeadPid = current?.pid != null && !isPidAlive(current.pid);
|
|
150
|
+
if (staleByAge || staleByDeadPid) {
|
|
151
|
+
try {
|
|
152
|
+
await unlinkFile(lockPath);
|
|
153
|
+
continue;
|
|
154
|
+
} catch (unlinkError) {
|
|
155
|
+
if (unlinkError?.code === 'ENOENT') continue;
|
|
156
|
+
lastError = unlinkError;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (i === retries - 1) throw e;
|
|
161
|
+
await sleepMs(delayMs);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!handle) {
|
|
166
|
+
throw lastError || new Error(`LOCK_NOT_ACQUIRED: ${lockPath}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return await fn();
|
|
171
|
+
} finally {
|
|
172
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getLeadSessionId(config) {
|
|
177
|
+
return config?.leadSessionId
|
|
178
|
+
|| config?.lead_session_id
|
|
179
|
+
|| config?.lead?.lead_session_id
|
|
180
|
+
|| config?.lead?.sessionId
|
|
181
|
+
|| null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function resolveTeamPaths(teamName) {
|
|
185
|
+
validateTeamName(teamName);
|
|
186
|
+
|
|
187
|
+
const teamDir = join(TEAMS_ROOT, teamName);
|
|
188
|
+
const configPath = join(teamDir, 'config.json');
|
|
189
|
+
const inboxesDir = join(teamDir, 'inboxes');
|
|
190
|
+
const config = await readJsonSafe(configPath);
|
|
191
|
+
const leadSessionId = getLeadSessionId(config);
|
|
192
|
+
|
|
193
|
+
const byTeam = join(TASKS_ROOT, teamName);
|
|
194
|
+
const byLeadSession = leadSessionId ? join(TASKS_ROOT, leadSessionId) : null;
|
|
195
|
+
|
|
196
|
+
let tasksDir = byTeam;
|
|
197
|
+
let tasksDirResolution = 'not_found';
|
|
198
|
+
if (existsSync(byTeam)) {
|
|
199
|
+
tasksDirResolution = 'team_name';
|
|
200
|
+
} else if (byLeadSession && existsSync(byLeadSession)) {
|
|
201
|
+
tasksDir = byLeadSession;
|
|
202
|
+
tasksDirResolution = 'lead_session_id';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
team_dir: teamDir,
|
|
207
|
+
config_path: configPath,
|
|
208
|
+
inboxes_dir: inboxesDir,
|
|
209
|
+
tasks_dir: tasksDir,
|
|
210
|
+
tasks_dir_resolution: tasksDirResolution,
|
|
211
|
+
lead_session_id: leadSessionId,
|
|
212
|
+
config,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function forceCleanupTeam(teamName) {
|
|
217
|
+
validateTeamName(teamName);
|
|
218
|
+
|
|
219
|
+
let paths;
|
|
220
|
+
try {
|
|
221
|
+
paths = await resolveTeamPaths(teamName);
|
|
222
|
+
} catch {
|
|
223
|
+
paths = {
|
|
224
|
+
team_dir: join(TEAMS_ROOT, teamName),
|
|
225
|
+
config_path: join(TEAMS_ROOT, teamName, 'config.json'),
|
|
226
|
+
tasks_dir: join(TASKS_ROOT, teamName),
|
|
227
|
+
lead_session_id: null,
|
|
228
|
+
config: null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const config = paths.config || await readJsonSafe(paths.config_path);
|
|
234
|
+
if (config && Array.isArray(config.members)) {
|
|
235
|
+
atomicWriteJson(paths.config_path, {
|
|
236
|
+
...config,
|
|
237
|
+
members: config.members.map((member) => ({ ...member, isActive: false })),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
|
|
242
|
+
const cleanupTargets = new Set([
|
|
243
|
+
paths.team_dir,
|
|
244
|
+
join(TASKS_ROOT, teamName),
|
|
245
|
+
paths.tasks_dir,
|
|
246
|
+
]);
|
|
247
|
+
if (paths.lead_session_id) {
|
|
248
|
+
cleanupTargets.add(join(TASKS_ROOT, paths.lead_session_id));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const targetPath of cleanupTargets) {
|
|
252
|
+
if (!targetPath) continue;
|
|
253
|
+
try {
|
|
254
|
+
rmSync(targetPath, { recursive: true, force: true });
|
|
255
|
+
} catch {}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function collectTaskFiles(tasksDir) {
|
|
260
|
+
if (!existsSync(tasksDir)) return [];
|
|
261
|
+
|
|
262
|
+
// 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
|
|
263
|
+
let dirMtime;
|
|
264
|
+
try { dirMtime = (await stat(tasksDir)).mtimeMs; } catch { return []; }
|
|
265
|
+
|
|
266
|
+
const cached = _dirCache.get(tasksDir);
|
|
267
|
+
if (cached && cached.mtimeMs === dirMtime) {
|
|
268
|
+
return cached.files;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const entries = await readdir(tasksDir);
|
|
272
|
+
const files = entries
|
|
273
|
+
.filter((name) => name.endsWith('.json'))
|
|
274
|
+
.filter((name) => !name.endsWith('.lock'))
|
|
275
|
+
.filter((name) => name !== '.highwatermark')
|
|
276
|
+
.map((name) => join(tasksDir, name));
|
|
277
|
+
|
|
278
|
+
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
279
|
+
return files;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readTaskFileCached(file) {
|
|
283
|
+
let fileMtime;
|
|
284
|
+
try {
|
|
285
|
+
fileMtime = (await stat(file)).mtimeMs;
|
|
286
|
+
} catch {
|
|
287
|
+
return { file, mtimeMs: null, json: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const contentCached = _taskContentCache.get(file);
|
|
291
|
+
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
292
|
+
return { file, mtimeMs: fileMtime, json: contentCached.data };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const json = await readJsonSafe(file);
|
|
296
|
+
if (json && isObject(json)) {
|
|
297
|
+
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { file, mtimeMs: fileMtime, json };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function locateTaskFile(tasksDir, taskId) {
|
|
304
|
+
const direct = join(tasksDir, `${taskId}.json`);
|
|
305
|
+
if (existsSync(direct)) return direct;
|
|
306
|
+
|
|
307
|
+
// ID→파일 인덱스 캐시
|
|
308
|
+
const indexed = _taskIdIndex.get(taskId);
|
|
309
|
+
if (indexed && existsSync(indexed)) return indexed;
|
|
310
|
+
|
|
311
|
+
// 캐시된 collectTaskFiles로 풀 스캔
|
|
312
|
+
const files = await collectTaskFiles(tasksDir);
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
if (basename(file, '.json') === taskId) {
|
|
315
|
+
_taskIdIndex.set(taskId, file);
|
|
316
|
+
return file;
|
|
317
|
+
}
|
|
318
|
+
const json = await readJsonSafe(file);
|
|
319
|
+
if (json && String(json.id || '') === taskId) {
|
|
320
|
+
_taskIdIndex.set(taskId, file);
|
|
321
|
+
return file;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isObject(v) {
|
|
328
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function teamInfo(args = {}) {
|
|
332
|
+
const { team_name, include_members = true, include_paths = true } = args;
|
|
333
|
+
try {
|
|
334
|
+
validateTeamName(team_name);
|
|
335
|
+
} catch {
|
|
336
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const paths = await resolveTeamPaths(team_name);
|
|
340
|
+
if (!existsSync(paths.team_dir)) {
|
|
341
|
+
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const members = Array.isArray(paths.config?.members) ? paths.config.members : [];
|
|
345
|
+
const leadAgentId = paths.config?.leadAgentId
|
|
346
|
+
|| paths.config?.lead_agent_id
|
|
347
|
+
|| members[0]?.agentId
|
|
348
|
+
|| null;
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
data: {
|
|
353
|
+
team: {
|
|
354
|
+
team_name,
|
|
355
|
+
description: paths.config?.description || null,
|
|
356
|
+
},
|
|
357
|
+
lead: {
|
|
358
|
+
lead_agent_id: leadAgentId,
|
|
359
|
+
lead_session_id: paths.lead_session_id,
|
|
360
|
+
},
|
|
361
|
+
...(include_members ? { members } : {}),
|
|
362
|
+
...(include_paths ? {
|
|
363
|
+
paths: {
|
|
364
|
+
config_path: paths.config_path,
|
|
365
|
+
tasks_dir: paths.tasks_dir,
|
|
366
|
+
inboxes_dir: paths.inboxes_dir,
|
|
367
|
+
tasks_dir_resolution: paths.tasks_dir_resolution,
|
|
368
|
+
},
|
|
369
|
+
} : {}),
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function teamTaskList(args = {}) {
|
|
375
|
+
const {
|
|
376
|
+
team_name,
|
|
377
|
+
owner,
|
|
378
|
+
statuses = [],
|
|
379
|
+
include_internal = false,
|
|
380
|
+
limit = 200,
|
|
381
|
+
} = args;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
validateTeamName(team_name);
|
|
385
|
+
} catch {
|
|
386
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const paths = await resolveTeamPaths(team_name);
|
|
390
|
+
if (paths.tasks_dir_resolution === 'not_found') {
|
|
391
|
+
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const statusSet = new Set((statuses || []).map((s) => String(s)));
|
|
395
|
+
const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
|
|
396
|
+
let parseWarnings = 0;
|
|
397
|
+
const files = await collectTaskFiles(paths.tasks_dir);
|
|
398
|
+
const records = await Promise.all(files.map((file) => readTaskFileCached(file)));
|
|
399
|
+
|
|
400
|
+
const tasks = [];
|
|
401
|
+
for (const { file, mtimeMs: fileMtime, json } of records) {
|
|
402
|
+
if (!json || !isObject(json)) {
|
|
403
|
+
parseWarnings += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!include_internal && json?.metadata?._internal === true) continue;
|
|
408
|
+
if (owner && String(json.owner || '') !== String(owner)) continue;
|
|
409
|
+
if (statusSet.size > 0 && !statusSet.has(String(json.status || ''))) continue;
|
|
410
|
+
|
|
411
|
+
tasks.push({
|
|
412
|
+
...json,
|
|
413
|
+
task_file: file,
|
|
414
|
+
mtime_ms: fileMtime,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
tasks.sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0));
|
|
419
|
+
const sliced = tasks.slice(0, maxCount);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
ok: true,
|
|
423
|
+
data: {
|
|
424
|
+
tasks: sliced,
|
|
425
|
+
count: sliced.length,
|
|
426
|
+
parse_warnings: parseWarnings,
|
|
427
|
+
tasks_dir: paths.tasks_dir,
|
|
428
|
+
tasks_dir_resolution: paths.tasks_dir_resolution,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// status 화이트리스트 (Claude Code API 호환)
|
|
434
|
+
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
435
|
+
|
|
436
|
+
export async function teamTaskUpdate(args = {}) {
|
|
437
|
+
// "failed" → "completed" + metadata.result 자동 매핑
|
|
438
|
+
if (String(args.status || '') === 'failed') {
|
|
439
|
+
args = {
|
|
440
|
+
...args,
|
|
441
|
+
status: 'completed',
|
|
442
|
+
metadata_patch: { ...(args.metadata_patch || {}), result: 'failed' },
|
|
443
|
+
};
|
|
444
|
+
} else if (args.status != null && !VALID_STATUSES.has(String(args.status))) {
|
|
445
|
+
return err('INVALID_STATUS', `유효하지 않은 status: ${args.status}. 허용: ${[...VALID_STATUSES].join(', ')}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const {
|
|
449
|
+
team_name,
|
|
450
|
+
task_id,
|
|
451
|
+
claim = false,
|
|
452
|
+
owner,
|
|
453
|
+
status,
|
|
454
|
+
subject,
|
|
455
|
+
description,
|
|
456
|
+
activeForm,
|
|
457
|
+
add_blocks = [],
|
|
458
|
+
add_blocked_by = [],
|
|
459
|
+
metadata_patch,
|
|
460
|
+
if_match_mtime_ms,
|
|
461
|
+
actor,
|
|
462
|
+
} = args;
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
validateTeamName(team_name);
|
|
466
|
+
} catch {
|
|
467
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!String(task_id || '').trim()) {
|
|
471
|
+
return err('INVALID_TASK_ID', 'task_id가 필요합니다');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const paths = await resolveTeamPaths(team_name);
|
|
475
|
+
if (paths.tasks_dir_resolution === 'not_found') {
|
|
476
|
+
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const taskFile = await locateTaskFile(paths.tasks_dir, String(task_id));
|
|
480
|
+
if (!taskFile) {
|
|
481
|
+
return err('TASK_NOT_FOUND', `task를 찾지 못했습니다: ${task_id}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const lockFile = `${taskFile}.lock`;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
return await withFileLock(lockFile, async () => {
|
|
488
|
+
const before = await readJsonSafe(taskFile);
|
|
489
|
+
if (!before || !isObject(before)) {
|
|
490
|
+
return err('INVALID_TASK_FILE', `task 파일 파싱 실패: ${taskFile}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let beforeMtime = Date.now();
|
|
494
|
+
try { beforeMtime = (await stat(taskFile)).mtimeMs; } catch {}
|
|
495
|
+
|
|
496
|
+
if (if_match_mtime_ms != null && Number(if_match_mtime_ms) !== Number(beforeMtime)) {
|
|
497
|
+
return err('MTIME_CONFLICT', 'if_match_mtime_ms가 일치하지 않습니다', {
|
|
498
|
+
task_file: taskFile,
|
|
499
|
+
mtime_ms: beforeMtime,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
504
|
+
let claimed = false;
|
|
505
|
+
let updated = false;
|
|
506
|
+
|
|
507
|
+
if (claim) {
|
|
508
|
+
const requestedOwner = String(owner || actor || '');
|
|
509
|
+
const ownerNow = String(before.owner || '');
|
|
510
|
+
const ownerCompatible = ownerNow === '' || requestedOwner === '' || ownerNow === requestedOwner;
|
|
511
|
+
const statusPending = String(before.status || '') === 'pending';
|
|
512
|
+
|
|
513
|
+
if (!statusPending || !ownerCompatible) {
|
|
514
|
+
return err('CLAIM_CONFLICT', 'task claim 충돌', {
|
|
515
|
+
task_before: before,
|
|
516
|
+
task_file: taskFile,
|
|
517
|
+
mtime_ms: beforeMtime,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (requestedOwner) after.owner = requestedOwner;
|
|
522
|
+
after.status = status || 'in_progress';
|
|
523
|
+
claimed = true;
|
|
524
|
+
updated = true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (owner != null && String(after.owner || '') !== String(owner)) {
|
|
528
|
+
after.owner = owner;
|
|
529
|
+
updated = true;
|
|
530
|
+
}
|
|
531
|
+
if (status != null && String(after.status || '') !== String(status)) {
|
|
532
|
+
after.status = status;
|
|
533
|
+
updated = true;
|
|
534
|
+
}
|
|
535
|
+
if (subject != null && String(after.subject || '') !== String(subject)) {
|
|
536
|
+
after.subject = subject;
|
|
537
|
+
updated = true;
|
|
538
|
+
}
|
|
539
|
+
if (description != null && String(after.description || '') !== String(description)) {
|
|
540
|
+
after.description = description;
|
|
541
|
+
updated = true;
|
|
542
|
+
}
|
|
543
|
+
if (activeForm != null && String(after.activeForm || '') !== String(activeForm)) {
|
|
544
|
+
after.activeForm = activeForm;
|
|
545
|
+
updated = true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (Array.isArray(add_blocks) && add_blocks.length > 0) {
|
|
549
|
+
const current = Array.isArray(after.blocks) ? [...after.blocks] : [];
|
|
550
|
+
for (const item of add_blocks) {
|
|
551
|
+
if (!current.includes(item)) current.push(item);
|
|
552
|
+
}
|
|
553
|
+
after.blocks = current;
|
|
554
|
+
updated = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (Array.isArray(add_blocked_by) && add_blocked_by.length > 0) {
|
|
558
|
+
const current = Array.isArray(after.blockedBy) ? [...after.blockedBy] : [];
|
|
559
|
+
for (const item of add_blocked_by) {
|
|
560
|
+
if (!current.includes(item)) current.push(item);
|
|
561
|
+
}
|
|
562
|
+
after.blockedBy = current;
|
|
563
|
+
updated = true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (isObject(metadata_patch)) {
|
|
567
|
+
const base = isObject(after.metadata) ? after.metadata : {};
|
|
568
|
+
after.metadata = { ...base, ...metadata_patch };
|
|
569
|
+
updated = true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (updated) {
|
|
573
|
+
atomicWriteJson(taskFile, after);
|
|
574
|
+
_invalidateCache(dirname(taskFile));
|
|
575
|
+
// 콘텐츠 캐시 무효화
|
|
576
|
+
_taskContentCache.delete(taskFile);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let afterMtime = beforeMtime;
|
|
580
|
+
try { afterMtime = (await stat(taskFile)).mtimeMs; } catch {}
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
data: {
|
|
585
|
+
claimed,
|
|
586
|
+
updated,
|
|
587
|
+
task_before: before,
|
|
588
|
+
task_after: updated ? after : before,
|
|
589
|
+
task_file: taskFile,
|
|
590
|
+
mtime_ms: afterMtime,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
} catch (e) {
|
|
595
|
+
return err('TASK_UPDATE_FAILED', e.message);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function sanitizeRecipientName(v) {
|
|
600
|
+
return String(v || 'team-lead').replace(/[\\/:*?"<>|]/g, '_');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function teamSendMessage(args = {}) {
|
|
604
|
+
const {
|
|
605
|
+
team_name,
|
|
606
|
+
from,
|
|
607
|
+
to = 'team-lead',
|
|
608
|
+
text,
|
|
609
|
+
summary,
|
|
610
|
+
color = 'blue',
|
|
611
|
+
} = args;
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
validateTeamName(team_name);
|
|
615
|
+
} catch {
|
|
616
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (!String(from || '').trim()) return err('INVALID_FROM', 'from이 필요합니다');
|
|
620
|
+
if (!String(text || '').trim()) return err('INVALID_TEXT', 'text가 필요합니다');
|
|
621
|
+
|
|
622
|
+
const paths = await resolveTeamPaths(team_name);
|
|
623
|
+
if (!existsSync(paths.team_dir)) {
|
|
624
|
+
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const recipient = sanitizeRecipientName(to);
|
|
628
|
+
const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
|
|
629
|
+
const lockFile = `${inboxFile}.lock`;
|
|
630
|
+
let message;
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const unreadCount = await withFileLock(lockFile, async () => {
|
|
634
|
+
const queue = await readJsonSafe(inboxFile);
|
|
635
|
+
const list = Array.isArray(queue) ? queue : [];
|
|
636
|
+
|
|
637
|
+
message = {
|
|
638
|
+
id: randomUUID(),
|
|
639
|
+
from: String(from),
|
|
640
|
+
text: String(text),
|
|
641
|
+
...(summary ? { summary: String(summary) } : {}),
|
|
642
|
+
timestamp: new Date().toISOString(),
|
|
643
|
+
color: String(color || 'blue'),
|
|
644
|
+
read: false,
|
|
645
|
+
};
|
|
646
|
+
list.push(message);
|
|
647
|
+
|
|
648
|
+
// inbox 정리: 최대 200개 유지, read + 1시간 경과 메시지 제거
|
|
649
|
+
const MAX_INBOX = 200;
|
|
650
|
+
if (list.length > MAX_INBOX) {
|
|
651
|
+
const ONE_HOUR_MS = 3600000;
|
|
652
|
+
const cutoff = Date.now() - ONE_HOUR_MS;
|
|
653
|
+
const pruned = list.filter((m) =>
|
|
654
|
+
m?.read !== true || !m?.timestamp || new Date(m.timestamp).getTime() > cutoff
|
|
655
|
+
);
|
|
656
|
+
list.length = 0;
|
|
657
|
+
list.push(...pruned);
|
|
658
|
+
if (list.length > MAX_INBOX) {
|
|
659
|
+
list.splice(0, list.length - MAX_INBOX);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
atomicWriteJson(inboxFile, list);
|
|
664
|
+
|
|
665
|
+
return list.filter((m) => m?.read !== true).length;
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
ok: true,
|
|
670
|
+
data: {
|
|
671
|
+
message_id: message.id,
|
|
672
|
+
recipient,
|
|
673
|
+
inbox_file: inboxFile,
|
|
674
|
+
queued_at: message.timestamp,
|
|
675
|
+
unread_count: unreadCount,
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
} catch (e) {
|
|
679
|
+
return err('SEND_MESSAGE_FAILED', e.message);
|
|
680
|
+
}
|
|
681
|
+
}
|