triflux 9.8.2 → 9.8.3

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/state.mjs ADDED
@@ -0,0 +1,245 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, renameSync, existsSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
8
+ const STATE_FILE_NAME = 'hub-state.json';
9
+ const LOCK_FILE_NAME = 'hub-start.lock';
10
+
11
+ let heldLockPath = null;
12
+ let heldLockFd = null;
13
+ let cachedVersionHash = null;
14
+
15
+ function getStateDir(options = {}) {
16
+ return options.stateDir || process.env.TFX_HUB_STATE_DIR?.trim() || join(homedir(), '.claude', 'cache', 'tfx-hub');
17
+ }
18
+
19
+ function getStatePath(options = {}) {
20
+ return join(getStateDir(options), STATE_FILE_NAME);
21
+ }
22
+
23
+ function getLockPath(options = {}) {
24
+ return options.lockPath || join(getStateDir(options), LOCK_FILE_NAME);
25
+ }
26
+
27
+ function sleep(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+
31
+ function isPidAlive(pid) {
32
+ if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return false;
33
+ try {
34
+ process.kill(Number(pid), 0);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function parseJson(text, fallback = null) {
42
+ try {
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return fallback;
46
+ }
47
+ }
48
+
49
+ function safeReplaceFile(tempPath, targetPath) {
50
+ try {
51
+ renameSync(tempPath, targetPath);
52
+ } catch (error) {
53
+ if (!['EEXIST', 'EPERM', 'EACCES'].includes(error?.code)) {
54
+ try { unlinkSync(tempPath); } catch {}
55
+ throw error;
56
+ }
57
+ try { unlinkSync(targetPath); } catch {}
58
+ renameSync(tempPath, targetPath);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
64
+ * 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
65
+ *
66
+ * @param {object} payload - 상태 데이터
67
+ * @param {number} payload.pid - 허브 프로세스 ID
68
+ * @param {number} payload.port - 허브 서버 포트
69
+ * @param {string} payload.version - 허브 버전
70
+ * @param {string} payload.sessionId - 현재 세션 ID
71
+ * @param {string} payload.startedAt - 시작 시각 (ISO 8601)
72
+ * @param {object} [options] - 옵션
73
+ * @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
74
+ * @returns {object} 기록된 상태 데이터
75
+ */
76
+ export function writeState({ pid, port, version, sessionId, startedAt }, options = {}) {
77
+ const stateDir = getStateDir(options);
78
+ const statePath = getStatePath(options);
79
+ const tempPath = join(stateDir, `${STATE_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
80
+ const payload = { pid, port, version, sessionId, startedAt };
81
+
82
+ mkdirSync(stateDir, { recursive: true });
83
+ writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
84
+ safeReplaceFile(tempPath, statePath);
85
+ return payload;
86
+ }
87
+
88
+ /**
89
+ * 파일로부터 허브의 현재 상태를 읽어옵니다.
90
+ *
91
+ * @param {object} [options] - 옵션
92
+ * @param {string} [options.stateDir] - 상태 파일이 저장된 디렉토리
93
+ * @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
94
+ */
95
+ export function readState(options = {}) {
96
+ const statePath = getStatePath(options);
97
+ try {
98
+ if (!existsSync(statePath)) return null;
99
+ return parseJson(readFileSync(statePath, 'utf8'), null);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 지정된 포트에서 실행 중인 허브 서버의 헬스 체크를 수행합니다.
107
+ *
108
+ * @param {number|string} port - 서버 포트
109
+ * @param {object} [options] - 옵션
110
+ * @param {number} [options.timeoutMs=1000] - 요청 타임아웃
111
+ * @param {string} [options.baseUrl] - 서버 베이스 URL
112
+ * @returns {Promise<boolean>} 서버 정상 작동 여부
113
+ */
114
+ export async function isServerHealthy(port, options = {}) {
115
+ const resolvedPort = Number(port);
116
+ if (!Number.isFinite(resolvedPort) || resolvedPort <= 0) return false;
117
+
118
+ const timeoutMs = Math.max(100, Number(options.timeoutMs) || 1000);
119
+ const baseUrl = options.baseUrl || `http://127.0.0.1:${resolvedPort}`;
120
+
121
+ try {
122
+ const response = await fetch(`${baseUrl}/health`, {
123
+ method: 'GET',
124
+ signal: AbortSignal.timeout(timeoutMs),
125
+ });
126
+ if (!response.ok) return false;
127
+ const body = await response.json().catch(() => null);
128
+ return body?.ok === true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 현재 프로젝트의 버전 해시를 생성합니다.
136
+ * package.json의 버전과 Git commit SHA를 조합합니다.
137
+ *
138
+ * @param {object} [options] - 옵션
139
+ * @param {boolean} [options.force=false] - 캐시를 무시하고 새로 생성할지 여부
140
+ * @returns {string} 버전 해시 문자열
141
+ */
142
+ export function getVersionHash(options = {}) {
143
+ if (cachedVersionHash && !options.force) return cachedVersionHash;
144
+
145
+ const packageJsonPath = join(PROJECT_ROOT, 'package.json');
146
+ const pkg = parseJson(readFileSync(packageJsonPath, 'utf8'), {});
147
+ const version = String(pkg?.version || '0.0.0').trim();
148
+
149
+ let sha = String(process.env.TFX_HUB_GIT_SHA || '').trim();
150
+ if (!sha) {
151
+ try {
152
+ sha = execSync('git rev-parse --short HEAD', {
153
+ cwd: PROJECT_ROOT,
154
+ encoding: 'utf8',
155
+ stdio: ['ignore', 'pipe', 'ignore'],
156
+ windowsHide: true,
157
+ }).trim();
158
+ } catch {
159
+ sha = '';
160
+ }
161
+ }
162
+
163
+ cachedVersionHash = sha ? `${version}-${sha}` : version;
164
+ return cachedVersionHash;
165
+ }
166
+
167
+ /**
168
+ * 허브 시작 시 중복 실행을 방지하기 위한 잠금(lock)을 획득합니다.
169
+ * 이미 실행 중인 다른 프로세스가 있는지 확인하고 유효한 잠금을 획득할 때까지 재시도합니다.
170
+ *
171
+ * @param {object} [options] - 옵션
172
+ * @param {number} [options.timeoutMs=3000] - 최대 대기 시간
173
+ * @param {number} [options.pollMs=50] - 재시도 간격
174
+ * @param {string} [options.lockPath] - 잠금 파일 경로
175
+ * @returns {Promise<{path: string}>} 잠금 파일 경로
176
+ * @throws {Error} 타임아웃 내에 잠금을 획득하지 못한 경우
177
+ */
178
+ export async function acquireLock(options = {}) {
179
+ if (heldLockFd !== null && heldLockPath) {
180
+ return { path: heldLockPath };
181
+ }
182
+
183
+ const lockPath = getLockPath(options);
184
+ const timeoutMs = Math.max(100, Number(options.timeoutMs) || 3000);
185
+ const pollMs = Math.max(10, Number(options.pollMs) || 50);
186
+ const deadline = Date.now() + timeoutMs;
187
+
188
+ mkdirSync(dirname(lockPath), { recursive: true });
189
+
190
+ while (Date.now() <= deadline) {
191
+ try {
192
+ const fd = openSync(lockPath, 'wx', 0o600);
193
+ writeFileSync(fd, `${JSON.stringify({
194
+ pid: process.pid,
195
+ createdAt: new Date().toISOString(),
196
+ }, null, 2)}\n`, 'utf8');
197
+ heldLockFd = fd;
198
+ heldLockPath = lockPath;
199
+ return { path: lockPath };
200
+ } catch (error) {
201
+ if (error?.code !== 'EEXIST') {
202
+ throw error;
203
+ }
204
+
205
+ try {
206
+ const raw = readFileSync(lockPath, 'utf8');
207
+ const data = parseJson(raw, {});
208
+ const stats = statSync(lockPath);
209
+ const staleByPid = !isPidAlive(data?.pid);
210
+ const staleByAge = Date.now() - stats.mtimeMs > timeoutMs;
211
+ if (staleByPid || staleByAge) {
212
+ try { unlinkSync(lockPath); } catch {}
213
+ continue;
214
+ }
215
+ } catch {}
216
+
217
+ await sleep(pollMs);
218
+ }
219
+ }
220
+
221
+ throw new Error(`hub start lock busy: ${lockPath}`);
222
+ }
223
+
224
+ /**
225
+ * 획득했던 잠금을 해제합니다. 잠금 파일을 삭제하고 관련 리소스를 정리합니다.
226
+ *
227
+ * @param {object} [options] - 옵션
228
+ * @param {string} [options.lockPath] - 명시적인 잠금 파일 경로
229
+ */
230
+ export function releaseLock(options = {}) {
231
+ const lockPath = options.lockPath || heldLockPath || getLockPath(options);
232
+
233
+ if (heldLockFd !== null) {
234
+ try { closeSync(heldLockFd); } catch {}
235
+ heldLockFd = null;
236
+ }
237
+
238
+ try {
239
+ if (existsSync(lockPath)) unlinkSync(lockPath);
240
+ } catch {}
241
+
242
+ if (!options.lockPath || options.lockPath === heldLockPath) {
243
+ heldLockPath = null;
244
+ }
245
+ }