@triflux/remote 10.0.0-alpha.1 → 10.0.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.
@@ -0,0 +1,478 @@
1
+ // hub/team/remote-watcher.mjs — tfx-spawn-* 세션 완료/입력대기 watcher
2
+ //
3
+ // 요구사항:
4
+ // 1) listSpawnSessions()로 tfx-spawn-* 세션 목록 조회
5
+ // 2) 각 세션의 pane 마지막 50줄을 10초 간격으로 폴링
6
+ // 3) 완료 패턴(__TRIFLUX_DONE__ 또는 프롬프트 idle) 감지
7
+ // 4) 완료/실패/입력대기 이벤트 emit
8
+ // 5) immutable status snapshot 제공
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import { EventEmitter } from "node:events";
12
+
13
+ import { detectInputWait, PROBE_LEVELS } from "./health-probe.mjs";
14
+ import { shellQuoteForHost, detectHostOs } from "@triflux/core/hub/lib/ssh-command.mjs";
15
+
16
+ export const REMOTE_WATCHER_STATES = Object.freeze({
17
+ WATCHING: "watching",
18
+ INPUT_WAIT: "input_wait",
19
+ COMPLETED: "completed",
20
+ FAILED: "failed",
21
+ });
22
+
23
+ export const REMOTE_WATCHER_DEFAULTS = Object.freeze({
24
+ captureLines: 50,
25
+ execTimeoutMs: 10_000,
26
+ intervalMs: 10_000,
27
+ paneSuffix: ":0.0",
28
+ sessionPrefix: "tfx-spawn-",
29
+ sshConnectTimeoutSec: 5,
30
+ });
31
+
32
+ const COMPLETION_TOKEN_RE = /__TRIFLUX_DONE__(?::([^:\r\n]+))?(?::(-?\d+))?/gu;
33
+ const BARE_PROMPT_RE = /^\s*(?:\u276f|\u2795|>)\s*$/u;
34
+ const BARE_INPUT_WAIT_PATTERN = />\s*$/.source;
35
+ const PROMPT_IDLE_PATTERNS = Object.freeze([
36
+ /^\s*PS [^\r\n>]*>\s*$/u,
37
+ /^\s*[a-zA-Z]:\\[^>\r\n]*>\s*$/u,
38
+ /^\s*[\w.-]+@[\w.-]+(?::[^\r\n]*)?[#$%]\s*$/u,
39
+ BARE_PROMPT_RE,
40
+ ]);
41
+
42
+ /** @deprecated shellQuoteForHost(value, os) 사용 권장 — OS-aware 쿼팅 */
43
+ function shellQuote(value, os) {
44
+ if (os) return shellQuoteForHost(value, os);
45
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
46
+ }
47
+
48
+ function freezeErrorRecord(error) {
49
+ if (!error) return null;
50
+ return Object.freeze({
51
+ message: String(error.message || error),
52
+ name: error.name || "Error",
53
+ });
54
+ }
55
+
56
+ function freezeSessionRecord(record) {
57
+ return Object.freeze({ ...record });
58
+ }
59
+
60
+ function freezeStatus(status) {
61
+ const frozenSessions = Object.freeze(
62
+ Object.fromEntries(
63
+ Object.entries(status.sessions || {})
64
+ .sort(([left], [right]) => left.localeCompare(right))
65
+ .map(([sessionName, record]) => [sessionName, freezeSessionRecord(record)]),
66
+ ),
67
+ );
68
+
69
+ return Object.freeze({
70
+ ...status,
71
+ lastError: freezeErrorRecord(status.lastError),
72
+ sessions: frozenSessions,
73
+ });
74
+ }
75
+
76
+ function toErrorRecord(error) {
77
+ return {
78
+ message: String(error?.message || error),
79
+ name: error?.name || "Error",
80
+ };
81
+ }
82
+
83
+ function getNonEmptyLines(text) {
84
+ return String(text || "")
85
+ .split(/\r?\n/u)
86
+ .map((line) => line.trimEnd())
87
+ .filter((line) => line.trim().length > 0);
88
+ }
89
+
90
+ function detectPromptIdle(captured) {
91
+ const lastLine = getNonEmptyLines(captured).at(-1) || "";
92
+ if (!lastLine) {
93
+ return { detected: false, line: "", pattern: null };
94
+ }
95
+
96
+ for (const pattern of PROMPT_IDLE_PATTERNS) {
97
+ if (pattern.test(lastLine)) {
98
+ return {
99
+ detected: true,
100
+ line: lastLine,
101
+ pattern: pattern.source,
102
+ };
103
+ }
104
+ }
105
+
106
+ return { detected: false, line: lastLine, pattern: null };
107
+ }
108
+
109
+ function hasMeaningfulActivity(captured) {
110
+ const lines = getNonEmptyLines(captured);
111
+ if (lines.length === 0) return false;
112
+
113
+ const promptIdle = detectPromptIdle(captured);
114
+ if (!promptIdle.detected) return true;
115
+
116
+ return lines.slice(0, -1).some((line) => line.trim().length > 0);
117
+ }
118
+
119
+ function detectCompletion(captured) {
120
+ const matches = Array.from(String(captured || "").matchAll(COMPLETION_TOKEN_RE));
121
+ const match = matches.at(-1);
122
+ if (!match) {
123
+ return {
124
+ detected: false,
125
+ exitCode: null,
126
+ match: null,
127
+ token: null,
128
+ };
129
+ }
130
+
131
+ const parsedExitCode = match[2] == null ? null : Number.parseInt(match[2], 10);
132
+ return {
133
+ detected: true,
134
+ exitCode: Number.isFinite(parsedExitCode) ? parsedExitCode : null,
135
+ match: match[0],
136
+ token: match[1] || null,
137
+ };
138
+ }
139
+
140
+ function parseSessionNames(rawOutput, sessionPrefix) {
141
+ return String(rawOutput || "")
142
+ .split(/\r?\n/u)
143
+ .map((line) => line.trim())
144
+ .filter(Boolean)
145
+ .map((line) => line.split(":")[0]?.trim())
146
+ .filter((sessionName) => Boolean(sessionName) && sessionName.startsWith(sessionPrefix));
147
+ }
148
+
149
+ function buildExecOptions(config) {
150
+ return {
151
+ encoding: "utf8",
152
+ timeout: config.execTimeoutMs,
153
+ windowsHide: true,
154
+ stdio: ["ignore", "pipe", "pipe"],
155
+ };
156
+ }
157
+
158
+ function runPsmuxCommand(args, config) {
159
+ const execFn = config.deps?.execFileSync || execFileSync;
160
+ const execOptions = buildExecOptions(config);
161
+
162
+ if (config.host) {
163
+ const hostOs = detectHostOs(config.host);
164
+ const sshArgs = [
165
+ "-o",
166
+ `ConnectTimeout=${config.sshConnectTimeoutSec}`,
167
+ "-o",
168
+ "BatchMode=yes",
169
+ config.host,
170
+ `psmux ${args.map((a) => shellQuoteForHost(a, hostOs)).join(" ")}`,
171
+ ];
172
+ return execFn("ssh", sshArgs, execOptions);
173
+ }
174
+
175
+ return execFn("psmux", args, execOptions);
176
+ }
177
+
178
+ function buildPaneTarget(sessionName, config) {
179
+ return `${sessionName}${config.paneSuffix}`;
180
+ }
181
+
182
+ function captureSpawnSession(sessionName, config) {
183
+ const paneTarget = buildPaneTarget(sessionName, config);
184
+ const output = runPsmuxCommand(
185
+ ["capture-pane", "-t", paneTarget, "-p", "-S", `-${config.captureLines}`],
186
+ config,
187
+ );
188
+ return {
189
+ paneTarget,
190
+ captured: String(output || "").replace(/\r/g, "").trimEnd(),
191
+ };
192
+ }
193
+
194
+ export function listSpawnSessions(opts = {}) {
195
+ const config = Object.freeze({
196
+ ...REMOTE_WATCHER_DEFAULTS,
197
+ ...opts,
198
+ });
199
+ const rawOutput = runPsmuxCommand(["list-sessions"], config);
200
+ return parseSessionNames(rawOutput, config.sessionPrefix);
201
+ }
202
+
203
+ function createSessionRecord(sessionName, config, now) {
204
+ return {
205
+ completionMatch: null,
206
+ exitCode: null,
207
+ hasActivity: false,
208
+ host: config.host || null,
209
+ inputWaitPattern: null,
210
+ lastEventAt: null,
211
+ lastOutput: "",
212
+ lastPollAt: null,
213
+ lastProbeLevel: PROBE_LEVELS.L1,
214
+ lastSeenAt: now,
215
+ paneTarget: buildPaneTarget(sessionName, config),
216
+ promptIdlePattern: null,
217
+ reason: "watching",
218
+ sessionName,
219
+ state: REMOTE_WATCHER_STATES.WATCHING,
220
+ };
221
+ }
222
+
223
+ function isTerminalState(state) {
224
+ return state === REMOTE_WATCHER_STATES.COMPLETED || state === REMOTE_WATCHER_STATES.FAILED;
225
+ }
226
+
227
+ export function createRemoteWatcher(opts = {}) {
228
+ const config = Object.freeze({
229
+ ...REMOTE_WATCHER_DEFAULTS,
230
+ ...opts,
231
+ });
232
+
233
+ const emitter = new EventEmitter();
234
+ const clearIntervalFn = config.deps?.clearInterval || clearInterval;
235
+ const nowFn = config.deps?.now || Date.now;
236
+ const setIntervalFn = config.deps?.setInterval || setInterval;
237
+
238
+ let intervalHandle = null;
239
+ let polling = false;
240
+ let status = freezeStatus({
241
+ host: config.host || null,
242
+ intervalMs: config.intervalMs,
243
+ lastError: null,
244
+ lastPollAt: null,
245
+ running: false,
246
+ sessions: {},
247
+ });
248
+
249
+ function setStatus(nextStatus) {
250
+ status = freezeStatus(nextStatus);
251
+ return status;
252
+ }
253
+
254
+ function emitSessionEvent(eventName, nextRecord, now) {
255
+ emitter.emit(eventName, Object.freeze({
256
+ exitCode: nextRecord.exitCode,
257
+ host: nextRecord.host,
258
+ inputWaitPattern: nextRecord.inputWaitPattern,
259
+ output: nextRecord.lastOutput,
260
+ paneTarget: nextRecord.paneTarget,
261
+ probeLevel: nextRecord.lastProbeLevel,
262
+ promptIdlePattern: nextRecord.promptIdlePattern,
263
+ reason: nextRecord.reason,
264
+ sessionName: nextRecord.sessionName,
265
+ state: nextRecord.state,
266
+ ts: now,
267
+ }));
268
+ }
269
+
270
+ function classifySession(previousRecord, captured, now) {
271
+ const completion = detectCompletion(captured);
272
+ const inputWait = detectInputWait(captured);
273
+ const promptIdle = detectPromptIdle(captured);
274
+ const hasActivity = previousRecord.hasActivity || hasMeaningfulActivity(captured);
275
+
276
+ let nextState = REMOTE_WATCHER_STATES.WATCHING;
277
+ let reason = "watching";
278
+ let lastProbeLevel = PROBE_LEVELS.L1;
279
+ let exitCode = null;
280
+ let eventName = null;
281
+
282
+ if (completion.detected) {
283
+ exitCode = completion.exitCode;
284
+ lastProbeLevel = PROBE_LEVELS.L3;
285
+ if (completion.exitCode != null && completion.exitCode !== 0) {
286
+ nextState = REMOTE_WATCHER_STATES.FAILED;
287
+ reason = "completion_token_nonzero";
288
+ eventName = "sessionFailed";
289
+ } else {
290
+ nextState = REMOTE_WATCHER_STATES.COMPLETED;
291
+ reason = "completion_token";
292
+ eventName = "sessionCompleted";
293
+ }
294
+ } else if (
295
+ promptIdle.detected
296
+ && hasActivity
297
+ && (!inputWait.detected || inputWait.pattern === BARE_INPUT_WAIT_PATTERN)
298
+ ) {
299
+ nextState = REMOTE_WATCHER_STATES.COMPLETED;
300
+ reason = "prompt_idle";
301
+ lastProbeLevel = PROBE_LEVELS.L3;
302
+ eventName = "sessionCompleted";
303
+ } else if (inputWait.detected) {
304
+ nextState = REMOTE_WATCHER_STATES.INPUT_WAIT;
305
+ reason = "input_wait";
306
+ lastProbeLevel = PROBE_LEVELS.L1;
307
+ if (previousRecord.state !== REMOTE_WATCHER_STATES.INPUT_WAIT) {
308
+ eventName = "sessionInputWait";
309
+ }
310
+ }
311
+
312
+ const nextRecord = {
313
+ ...previousRecord,
314
+ completionMatch: completion.match,
315
+ exitCode,
316
+ hasActivity,
317
+ inputWaitPattern: inputWait.detected ? inputWait.pattern : null,
318
+ lastOutput: captured,
319
+ lastPollAt: now,
320
+ lastProbeLevel,
321
+ lastSeenAt: now,
322
+ promptIdlePattern: promptIdle.detected ? promptIdle.pattern : null,
323
+ reason,
324
+ state: nextState,
325
+ };
326
+
327
+ if (
328
+ eventName
329
+ && previousRecord.state === nextRecord.state
330
+ && previousRecord.reason === nextRecord.reason
331
+ && previousRecord.exitCode === nextRecord.exitCode
332
+ ) {
333
+ return { eventName: null, nextRecord };
334
+ }
335
+
336
+ if (eventName) {
337
+ nextRecord.lastEventAt = now;
338
+ }
339
+
340
+ return { eventName, nextRecord };
341
+ }
342
+
343
+ function markMissingSession(previousRecord, now) {
344
+ if (isTerminalState(previousRecord.state)) {
345
+ return { eventName: null, nextRecord: previousRecord };
346
+ }
347
+
348
+ const nextRecord = {
349
+ ...previousRecord,
350
+ lastEventAt: now,
351
+ lastPollAt: now,
352
+ lastProbeLevel: PROBE_LEVELS.L0,
353
+ reason: "session_missing",
354
+ state: REMOTE_WATCHER_STATES.FAILED,
355
+ };
356
+
357
+ return { eventName: "sessionFailed", nextRecord };
358
+ }
359
+
360
+ function pollSessions() {
361
+ if (polling) return;
362
+ polling = true;
363
+
364
+ const now = nowFn();
365
+ const queuedEvents = [];
366
+ const nextSessions = { ...status.sessions };
367
+
368
+ try {
369
+ const activeSessions = listSpawnSessions(config);
370
+ const activeSet = new Set(activeSessions);
371
+
372
+ for (const sessionName of activeSessions) {
373
+ const previousRecord = nextSessions[sessionName]
374
+ ? { ...nextSessions[sessionName] }
375
+ : createSessionRecord(sessionName, config, now);
376
+
377
+ try {
378
+ const { paneTarget, captured } = captureSpawnSession(sessionName, config);
379
+ const { eventName, nextRecord } = classifySession(
380
+ { ...previousRecord, paneTarget },
381
+ captured,
382
+ now,
383
+ );
384
+ nextSessions[sessionName] = nextRecord;
385
+
386
+ if (eventName) {
387
+ queuedEvents.push({ eventName, record: nextRecord });
388
+ }
389
+ } catch (error) {
390
+ const failedRecord = {
391
+ ...previousRecord,
392
+ lastEventAt: now,
393
+ lastPollAt: now,
394
+ lastProbeLevel: PROBE_LEVELS.L0,
395
+ reason: "capture_failed",
396
+ state: REMOTE_WATCHER_STATES.FAILED,
397
+ };
398
+ nextSessions[sessionName] = failedRecord;
399
+ queuedEvents.push({ eventName: "sessionFailed", record: failedRecord });
400
+ setStatus({
401
+ ...status,
402
+ lastError: toErrorRecord(error),
403
+ lastPollAt: now,
404
+ running: true,
405
+ sessions: nextSessions,
406
+ });
407
+ }
408
+ }
409
+
410
+ for (const [sessionName, previousRecord] of Object.entries(status.sessions)) {
411
+ if (activeSet.has(sessionName)) continue;
412
+ const { eventName, nextRecord } = markMissingSession(previousRecord, now);
413
+ nextSessions[sessionName] = nextRecord;
414
+ if (eventName) {
415
+ queuedEvents.push({ eventName, record: nextRecord });
416
+ }
417
+ }
418
+
419
+ setStatus({
420
+ ...status,
421
+ lastError: null,
422
+ lastPollAt: now,
423
+ running: true,
424
+ sessions: nextSessions,
425
+ });
426
+
427
+ for (const { eventName, record } of queuedEvents) {
428
+ emitSessionEvent(eventName, record, now);
429
+ }
430
+ } catch (error) {
431
+ setStatus({
432
+ ...status,
433
+ lastError: toErrorRecord(error),
434
+ lastPollAt: now,
435
+ running: true,
436
+ });
437
+ } finally {
438
+ polling = false;
439
+ }
440
+ }
441
+
442
+ function start() {
443
+ if (intervalHandle) return;
444
+
445
+ setStatus({
446
+ ...status,
447
+ lastError: null,
448
+ running: true,
449
+ });
450
+
451
+ intervalHandle = setIntervalFn(() => {
452
+ pollSessions();
453
+ }, config.intervalMs);
454
+ intervalHandle?.unref?.();
455
+
456
+ pollSessions();
457
+ }
458
+
459
+ function stop() {
460
+ if (intervalHandle) {
461
+ clearIntervalFn(intervalHandle);
462
+ intervalHandle = null;
463
+ }
464
+
465
+ setStatus({
466
+ ...status,
467
+ running: false,
468
+ });
469
+ }
470
+
471
+ return Object.freeze({
472
+ getStatus: () => status,
473
+ off: emitter.off.bind(emitter),
474
+ on: emitter.on.bind(emitter),
475
+ start,
476
+ stop,
477
+ });
478
+ }
@@ -0,0 +1,169 @@
1
+ const DEFAULT_TIMEOUT_MS = 2500;
2
+
3
+ function resolveFetch(fetchImpl) {
4
+ if (typeof fetchImpl === "function") return fetchImpl;
5
+ if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
6
+ return null;
7
+ }
8
+
9
+ function normalizeHubBaseUrl(hubUrl) {
10
+ return String(hubUrl || "").replace(/\/+$/, "").replace(/\/mcp$/, "");
11
+ }
12
+
13
+ function safeAbortSignal(timeoutMs) {
14
+ if (typeof AbortSignal?.timeout !== "function") return undefined;
15
+ return AbortSignal.timeout(timeoutMs);
16
+ }
17
+
18
+ async function safeJson(res) {
19
+ try {
20
+ return await res.json();
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function normalizeLeadCommandMessage(message) {
27
+ const payload = message?.payload && typeof message.payload === "object" ? message.payload : {};
28
+ const command = String(payload.command || "").trim().toLowerCase();
29
+ if (!command) return null;
30
+ return {
31
+ messageId: message.id || null,
32
+ topic: message.topic || "lead.control",
33
+ fromAgent: message.from_agent || payload.issued_by || "lead",
34
+ command,
35
+ reason: payload.reason || "",
36
+ payload,
37
+ traceId: message.trace_id || null,
38
+ correlationId: message.correlation_id || null,
39
+ createdAtMs: message.created_at_ms || null,
40
+ };
41
+ }
42
+
43
+ export async function subscribeToLeadCommands({
44
+ hubUrl,
45
+ agentId,
46
+ maxMessages = 10,
47
+ autoAck = true,
48
+ timeoutMs = DEFAULT_TIMEOUT_MS,
49
+ topics = ["lead.control"],
50
+ onCommand = null,
51
+ fetchImpl,
52
+ } = {}) {
53
+ const requestFetch = resolveFetch(fetchImpl);
54
+ if (!requestFetch) {
55
+ return { ok: false, error: "FETCH_UNAVAILABLE", commands: [] };
56
+ }
57
+
58
+ const hubBase = normalizeHubBaseUrl(hubUrl);
59
+ if (!hubBase) {
60
+ return { ok: false, error: "HUB_URL_REQUIRED", commands: [] };
61
+ }
62
+
63
+ const normalizedAgentId = String(agentId || "").trim();
64
+ if (!normalizedAgentId) {
65
+ return { ok: false, error: "AGENT_ID_REQUIRED", commands: [] };
66
+ }
67
+
68
+ try {
69
+ const res = await requestFetch(`${hubBase}/bridge/context`, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({
73
+ agent_id: normalizedAgentId,
74
+ topics: Array.isArray(topics) ? topics : ["lead.control"],
75
+ max_messages: maxMessages,
76
+ auto_ack: autoAck,
77
+ }),
78
+ signal: safeAbortSignal(timeoutMs),
79
+ });
80
+
81
+ const body = await safeJson(res);
82
+ const messages = Array.isArray(body?.data?.messages) ? body.data.messages : [];
83
+ const commands = messages
84
+ .filter((message) => message?.topic === "lead.control")
85
+ .map(normalizeLeadCommandMessage)
86
+ .filter(Boolean);
87
+
88
+ if (typeof onCommand === "function") {
89
+ for (const command of commands) {
90
+ await onCommand(command);
91
+ }
92
+ }
93
+
94
+ return {
95
+ ok: res.ok && body?.ok !== false,
96
+ status: res.status,
97
+ commands,
98
+ messageCount: messages.length,
99
+ body,
100
+ };
101
+ } catch (error) {
102
+ return {
103
+ ok: false,
104
+ error: "LEAD_COMMAND_SUBSCRIBE_FAILED",
105
+ message: error?.message || "lead command subscribe failed",
106
+ commands: [],
107
+ };
108
+ }
109
+ }
110
+
111
+ export async function getTeamStatus({
112
+ hubUrl,
113
+ scope = "hub",
114
+ agentId,
115
+ includeMetrics = true,
116
+ method = "GET",
117
+ timeoutMs = DEFAULT_TIMEOUT_MS,
118
+ fetchImpl,
119
+ } = {}) {
120
+ const requestFetch = resolveFetch(fetchImpl);
121
+ if (!requestFetch) {
122
+ return { ok: false, error: "FETCH_UNAVAILABLE" };
123
+ }
124
+
125
+ const hubBase = normalizeHubBaseUrl(hubUrl);
126
+ if (!hubBase) {
127
+ return { ok: false, error: "HUB_URL_REQUIRED" };
128
+ }
129
+
130
+ const normalizedMethod = String(method || "GET").toUpperCase() === "POST" ? "POST" : "GET";
131
+ const statusScope = String(scope || "hub").trim() || "hub";
132
+
133
+ let endpoint = `${hubBase}/bridge/status`;
134
+ const options = {
135
+ method: normalizedMethod,
136
+ headers: { "Content-Type": "application/json" },
137
+ signal: safeAbortSignal(timeoutMs),
138
+ };
139
+
140
+ if (normalizedMethod === "GET") {
141
+ const params = new URLSearchParams({ scope: statusScope });
142
+ if (agentId) params.set("agent_id", String(agentId));
143
+ if (!includeMetrics) params.set("include_metrics", "0");
144
+ endpoint = `${endpoint}?${params.toString()}`;
145
+ } else {
146
+ options.body = JSON.stringify({
147
+ scope: statusScope,
148
+ agent_id: agentId || undefined,
149
+ include_metrics: includeMetrics,
150
+ });
151
+ }
152
+
153
+ try {
154
+ const res = await requestFetch(endpoint, options);
155
+ const body = await safeJson(res);
156
+ return {
157
+ ok: res.ok && body?.ok !== false,
158
+ status: res.status,
159
+ body,
160
+ data: body?.data || null,
161
+ };
162
+ } catch (error) {
163
+ return {
164
+ ok: false,
165
+ error: "TEAM_STATUS_FETCH_FAILED",
166
+ message: error?.message || "team status fetch failed",
167
+ };
168
+ }
169
+ }
@@ -7,7 +7,7 @@ import { dirname, join, resolve } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
 
9
9
  import { forceCleanupTeam } from "./nativeProxy.mjs";
10
- import { isPidAlive } from "../lib/process-utils.mjs";
10
+ import { isPidAlive } from "@triflux/core/hub/lib/process-utils.mjs";
11
11
 
12
12
  export const TEAM_STATE_FILE_NAME = "team-state.json";
13
13
  export const STALE_TEAM_MAX_AGE_MS = 60 * 60 * 1000;