triflux 3.3.0-dev.3 → 3.3.0-dev.6

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.
@@ -1,25 +1,29 @@
1
1
  // hub/team/nativeProxy.mjs
2
2
  // Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
3
3
 
4
- import {
5
- existsSync,
6
- mkdirSync,
7
- renameSync,
8
- statSync,
9
- unlinkSync,
10
- writeFileSync,
11
- openSync,
12
- closeSync,
13
- } from 'node:fs';
14
- import { readdir, stat, readFile } from 'node:fs/promises';
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import {
12
+ open as openFile,
13
+ readdir,
14
+ readFile,
15
+ stat,
16
+ unlink as unlinkFile,
17
+ } from 'node:fs/promises';
15
18
  import { basename, dirname, join } from 'node:path';
16
19
  import { homedir } from 'node:os';
17
20
  import { randomUUID } from 'node:crypto';
18
21
 
19
22
  const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
20
23
  const CLAUDE_HOME = join(homedir(), '.claude');
21
- const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
22
- const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
24
+ const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
25
+ const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
26
+ const LOCK_STALE_MS = 30000;
23
27
 
24
28
  // ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
25
29
  const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
@@ -66,29 +70,118 @@ function atomicWriteJson(path, value) {
66
70
  }
67
71
  }
68
72
 
69
- async function sleepMs(ms) {
70
- return new Promise((r) => setTimeout(r, ms));
71
- }
72
-
73
- async function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
74
- let fd = null;
75
- for (let i = 0; i < retries; i += 1) {
76
- try {
77
- fd = openSync(lockPath, 'wx');
78
- break;
79
- } catch (e) {
80
- if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
81
- await sleepMs(delayMs);
82
- }
83
- }
84
-
85
- try {
86
- return await fn();
87
- } finally {
88
- try { if (fd != null) closeSync(fd); } catch {}
89
- try { unlinkSync(lockPath); } catch {}
90
- }
91
- }
73
+ async function sleepMs(ms) {
74
+ return new Promise((r) => setTimeout(r, ms));
75
+ }
76
+
77
+ async function readLockInfo(lockPath) {
78
+ let lockStat;
79
+ try {
80
+ lockStat = await stat(lockPath);
81
+ } catch {
82
+ return null;
83
+ }
84
+
85
+ let parsed = null;
86
+ try {
87
+ parsed = JSON.parse(await readFile(lockPath, 'utf8'));
88
+ } catch {}
89
+
90
+ const now = Date.now();
91
+ const createdAtMs = Number(
92
+ parsed?.created_at_ms
93
+ ?? parsed?.timestamp_ms
94
+ ?? parsed?.timestamp
95
+ ?? lockStat.mtimeMs,
96
+ );
97
+ const pid = Number(parsed?.pid);
98
+
99
+ return {
100
+ token: typeof parsed?.token === 'string' ? parsed.token : null,
101
+ pid: Number.isInteger(pid) && pid > 0 ? pid : null,
102
+ created_at_ms: Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs,
103
+ mtime_ms: lockStat.mtimeMs,
104
+ age_ms: Math.max(0, now - (Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs)),
105
+ };
106
+ }
107
+
108
+ function isPidAlive(pid) {
109
+ if (!Number.isInteger(pid) || pid <= 0) return false;
110
+
111
+ try {
112
+ process.kill(pid, 0);
113
+ return true;
114
+ } catch (e) {
115
+ if (e?.code === 'EPERM') return true;
116
+ if (e?.code === 'ESRCH') return false;
117
+ return false;
118
+ }
119
+ }
120
+
121
+ async function releaseFileLock(lockPath, token, handle) {
122
+ try { await handle?.close(); } catch {}
123
+
124
+ try {
125
+ const current = await readLockInfo(lockPath);
126
+ if (!current || current.token === token) {
127
+ await unlinkFile(lockPath);
128
+ }
129
+ } catch {}
130
+ }
131
+
132
+ async function withFileLock(lockPath, fn, retries = 20, delayMs = 25, staleMs = LOCK_STALE_MS) {
133
+ mkdirSync(dirname(lockPath), { recursive: true });
134
+ const lockOwner = {
135
+ pid: process.pid,
136
+ token: randomUUID(),
137
+ created_at: new Date().toISOString(),
138
+ created_at_ms: Date.now(),
139
+ };
140
+ let handle = null;
141
+ let lastError = null;
142
+
143
+ for (let i = 0; i < retries; i += 1) {
144
+ try {
145
+ handle = await openFile(lockPath, 'wx');
146
+ try {
147
+ await handle.writeFile(`${JSON.stringify(lockOwner)}\n`, 'utf8');
148
+ } catch (writeError) {
149
+ await releaseFileLock(lockPath, lockOwner.token, handle);
150
+ throw writeError;
151
+ }
152
+ break;
153
+ } catch (e) {
154
+ lastError = e;
155
+ if (e?.code !== 'EEXIST') throw e;
156
+
157
+ const current = await readLockInfo(lockPath);
158
+ const staleByAge = !current || current.age_ms > staleMs;
159
+ const staleByDeadPid = current?.pid != null && !isPidAlive(current.pid);
160
+ if (staleByAge || staleByDeadPid) {
161
+ try {
162
+ await unlinkFile(lockPath);
163
+ continue;
164
+ } catch (unlinkError) {
165
+ if (unlinkError?.code === 'ENOENT') continue;
166
+ lastError = unlinkError;
167
+ }
168
+ }
169
+
170
+ if (i === retries - 1) throw e;
171
+ await sleepMs(delayMs);
172
+ }
173
+ }
174
+
175
+ if (!handle) {
176
+ throw lastError || new Error(`LOCK_NOT_ACQUIRED: ${lockPath}`);
177
+ }
178
+
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ await releaseFileLock(lockPath, lockOwner.token, handle);
183
+ }
184
+ }
92
185
 
93
186
  function getLeadSessionId(config) {
94
187
  return config?.leadSessionId
@@ -130,8 +223,8 @@ export async function resolveTeamPaths(teamName) {
130
223
  };
131
224
  }
132
225
 
133
- async function collectTaskFiles(tasksDir) {
134
- if (!existsSync(tasksDir)) return [];
226
+ async function collectTaskFiles(tasksDir) {
227
+ if (!existsSync(tasksDir)) return [];
135
228
 
136
229
  // 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
137
230
  let dirMtime;
@@ -149,9 +242,30 @@ async function collectTaskFiles(tasksDir) {
149
242
  .filter((name) => name !== '.highwatermark')
150
243
  .map((name) => join(tasksDir, name));
151
244
 
152
- _dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
153
- return files;
154
- }
245
+ _dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
246
+ return files;
247
+ }
248
+
249
+ async function readTaskFileCached(file) {
250
+ let fileMtime;
251
+ try {
252
+ fileMtime = (await stat(file)).mtimeMs;
253
+ } catch {
254
+ return { file, mtimeMs: null, json: null };
255
+ }
256
+
257
+ const contentCached = _taskContentCache.get(file);
258
+ if (contentCached && contentCached.mtimeMs === fileMtime) {
259
+ return { file, mtimeMs: fileMtime, json: contentCached.data };
260
+ }
261
+
262
+ const json = await readJsonSafe(file);
263
+ if (json && isObject(json)) {
264
+ _taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
265
+ }
266
+
267
+ return { file, mtimeMs: fileMtime, json };
268
+ }
155
269
 
156
270
  async function locateTaskFile(tasksDir, taskId) {
157
271
  const direct = join(tasksDir, `${taskId}.json`);
@@ -244,30 +358,17 @@ export async function teamTaskList(args = {}) {
244
358
  return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
245
359
  }
246
360
 
247
- const statusSet = new Set((statuses || []).map((s) => String(s)));
248
- const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
249
- let parseWarnings = 0;
250
-
251
- const tasks = [];
252
- for (const file of await collectTaskFiles(paths.tasks_dir)) {
253
- // mtime 기반 task 콘텐츠 캐시 — 변경 없으면 파일 읽기 생략
254
- let fileMtime = Date.now();
255
- try { fileMtime = (await stat(file)).mtimeMs; } catch {}
256
-
257
- const contentCached = _taskContentCache.get(file);
258
- let json;
259
- if (contentCached && contentCached.mtimeMs === fileMtime) {
260
- json = contentCached.data;
261
- } else {
262
- json = await readJsonSafe(file);
263
- if (json && isObject(json)) {
264
- _taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
265
- }
266
- }
267
-
268
- if (!json || !isObject(json)) {
269
- parseWarnings += 1;
270
- continue;
361
+ const statusSet = new Set((statuses || []).map((s) => String(s)));
362
+ const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
363
+ let parseWarnings = 0;
364
+ const files = await collectTaskFiles(paths.tasks_dir);
365
+ const records = await Promise.all(files.map((file) => readTaskFileCached(file)));
366
+
367
+ const tasks = [];
368
+ for (const { file, mtimeMs: fileMtime, json } of records) {
369
+ if (!json || !isObject(json)) {
370
+ parseWarnings += 1;
371
+ continue;
271
372
  }
272
373
 
273
374
  if (!include_internal && json?.metadata?._internal === true) continue;
@@ -440,13 +541,13 @@ export async function teamTaskUpdate(args = {}) {
440
541
  _invalidateCache(dirname(taskFile));
441
542
  // 콘텐츠 캐시 무효화
442
543
  _taskContentCache.delete(taskFile);
443
- }
444
-
445
- let afterMtime = beforeMtime;
446
- try { afterMtime = statSync(taskFile).mtimeMs; } catch {}
447
-
448
- return {
449
- ok: true,
544
+ }
545
+
546
+ let afterMtime = beforeMtime;
547
+ try { afterMtime = (await stat(taskFile)).mtimeMs; } catch {}
548
+
549
+ return {
550
+ ok: true,
450
551
  data: {
451
552
  claimed,
452
553
  updated,
package/hub/tools.mjs CHANGED
@@ -338,9 +338,9 @@ export function createTools(store, router, hitl, pipe = null) {
338
338
  },
339
339
 
340
340
  // ── 13. team_task_list ──
341
- {
342
- name: 'team_task_list',
343
- description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다',
341
+ {
342
+ name: 'team_task_list',
343
+ description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다. 실패 판정은 completed + metadata.result도 함께 확인해야 합니다',
344
344
  inputSchema: {
345
345
  type: 'object',
346
346
  required: ['team_name'],
@@ -362,9 +362,9 @@ export function createTools(store, router, hitl, pipe = null) {
362
362
  },
363
363
 
364
364
  // ── 14. team_task_update ──
365
- {
366
- name: 'team_task_update',
367
- description: 'Claude Native Teams task를 claim/update 합니다',
365
+ {
366
+ name: 'team_task_update',
367
+ description: 'Claude Native Teams task를 claim/update 합니다. status: "failed" 입력은 completed + metadata.result="failed"로 정규화됩니다',
368
368
  inputSchema: {
369
369
  type: 'object',
370
370
  required: ['team_name', 'task_id'],