@triflux/remote 10.50.1 → 10.50.2

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/cto/hygiene.mjs CHANGED
@@ -1,20 +1,11 @@
1
- import { createHash } from "node:crypto";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import {
3
- closeSync,
4
- existsSync,
5
- mkdirSync,
6
- openSync,
7
- readFileSync,
8
- statSync,
9
- unlinkSync,
10
- writeFileSync,
11
- } from "node:fs";
12
- import { homedir } from "node:os";
13
- import { basename, dirname, join } from "node:path";
14
- import { getCtoHygieneApplyMode } from "../hub/lib/cto-env.mjs";
15
- import { appendCtoEvent } from "./events.mjs";
16
- import { applyHygieneArchiveActions } from "./hygiene-actions.mjs";
17
- import { resolveLakeRootDir } from "./lake-root.mjs";
4
+ normalizeLiveSession,
5
+ readJsonLines,
6
+ readSynapseSnapshot,
7
+ resolveLakeRootDir,
8
+ } from "./lake-root.mjs";
18
9
 
19
10
  const COUNT_KEYS = [
20
11
  "active_tasks",
@@ -29,10 +20,6 @@ function hasFlag(args, flag) {
29
20
  return Array.isArray(args) && args.includes(flag);
30
21
  }
31
22
 
32
- function sleep(ms) {
33
- return new Promise((resolve) => setTimeout(resolve, ms));
34
- }
35
-
36
23
  function writeJson(stdout, payload) {
37
24
  stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
38
25
  }
@@ -41,39 +28,6 @@ function readJson(filePath) {
41
28
  return JSON.parse(readFileSync(filePath, "utf8"));
42
29
  }
43
30
 
44
- function firstExisting(paths) {
45
- return paths.filter(Boolean).find((path) => existsSync(path)) || null;
46
- }
47
-
48
- function toIsoTime(value) {
49
- if (typeof value === "string" && value.trim()) return value;
50
- if (typeof value === "number" && Number.isFinite(value)) {
51
- return new Date(value).toISOString();
52
- }
53
- return null;
54
- }
55
-
56
- function normalizeLiveSession(session) {
57
- return {
58
- sessionId: String(session?.sessionId || session?.session_id || ""),
59
- phase:
60
- typeof session?.phase === "string"
61
- ? session.phase
62
- : typeof session?.status === "string"
63
- ? session.status
64
- : "active",
65
- agent_id:
66
- typeof session?.agent_id === "string"
67
- ? session.agent_id
68
- : typeof session?.agentId === "string"
69
- ? session.agentId
70
- : null,
71
- started_at: toIsoTime(
72
- session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
73
- ),
74
- };
75
- }
76
-
77
31
  function normalizeOverlay(value) {
78
32
  const sessions = Array.isArray(value)
79
33
  ? value
@@ -89,28 +43,10 @@ function normalizeOverlay(value) {
89
43
  };
90
44
  }
91
45
 
92
- async function readSynapseWithRegistry(opts = {}) {
93
- const rootDir = opts.rootDir || process.cwd();
94
- const persistPath = firstExisting([
95
- opts.synapsePersistPath,
96
- join(rootDir, ".triflux", "synapse-registry.json"),
97
- join(rootDir, ".triflux", "synapse", "registry.json"),
98
- join(homedir(), ".claude", "cache", "tfx-hub", "synapse-sessions.json"),
99
- join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
100
- ]);
101
- if (!persistPath) return { live_sessions: [] };
102
-
103
- const { createSynapseRegistry } = await import(
104
- "../hub/team/synapse-registry.mjs"
105
- );
106
- const registry = createSynapseRegistry({ persistPath });
107
- return normalizeOverlay(registry.getActive());
108
- }
109
-
110
46
  async function readLiveOverlay(opts = {}) {
111
47
  if (opts.overlay) return normalizeOverlay(opts.overlay);
112
48
  try {
113
- const reader = opts.synapseReader || readSynapseWithRegistry;
49
+ const reader = opts.synapseReader || readSynapseSnapshot;
114
50
  return normalizeOverlay(
115
51
  await reader({
116
52
  rootDir: opts.rootDir,
@@ -123,21 +59,6 @@ async function readLiveOverlay(opts = {}) {
123
59
  }
124
60
  }
125
61
 
126
- function readJsonLines(filePath) {
127
- if (!existsSync(filePath)) return [];
128
- return readFileSync(filePath, "utf8")
129
- .split(/\r?\n/u)
130
- .map((line) => line.trim())
131
- .filter(Boolean)
132
- .flatMap((line) => {
133
- try {
134
- return [JSON.parse(line)];
135
- } catch {
136
- return [];
137
- }
138
- });
139
- }
140
-
141
62
  function eventTime(entry) {
142
63
  return typeof entry?.ts === "string" ? entry.ts : "";
143
64
  }
@@ -429,271 +350,30 @@ export function projectCtoHygiene({
429
350
  };
430
351
  }
431
352
 
432
- function safeLabel(value) {
433
- return (
434
- String(value || "project")
435
- .toLowerCase()
436
- .replace(/[^a-z0-9._-]+/gu, "-")
437
- .replace(/^-+|-+$/gu, "")
438
- .slice(0, 40) || "project"
439
- );
440
- }
441
-
442
- function rootHash(rootDir) {
443
- return createHash("sha256")
444
- .update(String(rootDir || ""))
445
- .digest("hex")
446
- .slice(0, 12);
447
- }
448
-
449
- export function ctoHygieneStewardLockPath(rootDir, lakeRoot) {
450
- const label = safeLabel(basename(rootDir || "project"));
451
- return join(lakeRoot, "stewards", `${label}-${rootHash(rootDir)}.apply.lock`);
452
- }
453
-
454
- export async function acquireCtoHygieneStewardLock(
455
- rootDir,
456
- lakeRoot,
457
- opts = {},
458
- ) {
459
- const lockPath =
460
- opts.lockPath || ctoHygieneStewardLockPath(rootDir, lakeRoot);
461
- const timeoutMs = Math.max(0, Number(opts.timeoutMs) || 3000);
462
- const retryMs = Math.max(1, Number(opts.retryMs) || 50);
463
- const staleMs = Math.max(1000, Number(opts.staleMs) || 10 * 60_000);
464
- const start = Date.now();
465
-
466
- mkdirSync(dirname(lockPath), { recursive: true });
467
-
468
- while (true) {
469
- let fd = null;
470
- try {
471
- fd = openSync(lockPath, "wx", 0o600);
472
- writeFileSync(
473
- fd,
474
- `${JSON.stringify(
475
- {
476
- pid: process.pid,
477
- project_root: rootDir,
478
- project_root_hash: rootHash(rootDir),
479
- created_at: new Date().toISOString(),
480
- },
481
- null,
482
- 2,
483
- )}\n`,
484
- "utf8",
485
- );
486
- return {
487
- path: lockPath,
488
- project_root: rootDir,
489
- project_root_hash: rootHash(rootDir),
490
- release() {
491
- try {
492
- closeSync(fd);
493
- } catch {}
494
- try {
495
- unlinkSync(lockPath);
496
- } catch {}
497
- },
498
- };
499
- } catch (error) {
500
- if (fd !== null) {
501
- try {
502
- closeSync(fd);
503
- } catch {}
504
- }
505
- if (error?.code !== "EEXIST") throw error;
506
- try {
507
- if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
508
- unlinkSync(lockPath);
509
- continue;
510
- }
511
- } catch {}
512
- if (Date.now() - start >= timeoutMs) {
513
- throw new Error(`cto hygiene steward lock busy: ${lockPath}`);
514
- }
515
- await sleep(retryMs);
516
- }
517
- }
518
- }
519
-
520
- const APPLICABLE_STATUSES = new Set([
521
- "active",
522
- "stale",
523
- "superseded",
524
- "orphaned",
525
- "hidden",
526
- "completed",
527
- ]);
528
-
529
- function isApplicableRow(row) {
530
- return Boolean(row?.action) || APPLICABLE_STATUSES.has(row?.status);
531
- }
532
-
533
- const ARCHIVE_ACK_STATUSES = new Set(["archived", "already_archived"]);
534
-
535
- function hygieneApplyActor(opts = {}) {
536
- const actor = { cli: "tfx cto hygiene --apply" };
537
- if (typeof opts.actorSessionId === "string" && opts.actorSessionId.trim()) {
538
- actor.session_id = opts.actorSessionId.trim();
353
+ export async function runHygiene(args = [], opts = {}) {
354
+ if (args.some((arg) => !["--dry-run", "--json"].includes(arg))) {
355
+ throw new Error("tfx cto hygiene supports only --dry-run and --json");
539
356
  }
540
- return actor;
541
- }
542
-
543
- async function applyHygieneRows({
544
- rootDir,
545
- lakeRoot,
546
- projection,
547
- steward,
548
- active,
549
- approvals,
550
- opts,
551
- }) {
552
- const rows = projection.rows.filter(isApplicableRow);
553
- const appended = [];
554
- const now = opts.now || new Date().toISOString();
555
- const actions = await applyHygieneArchiveActions({
556
- rootDir,
557
- lakeRoot,
558
- projection: { ...projection, rows },
559
- dryRun: false,
560
- now,
561
- applyMode: opts.applyMode || getCtoHygieneApplyMode(),
562
- active,
563
- humanAck: opts.humanAck,
564
- humanGate: opts.humanGate,
565
- actorSessionId: opts.actorSessionId,
566
- approvals,
567
- fsOps: opts.fsOps,
568
- });
569
- const operationByKey = new Map(
570
- (actions.operations || []).map((operation) => [
571
- operation.hygiene_key,
572
- operation,
573
- ]),
574
- );
575
- for (const row of rows) {
576
- const hygieneKey = hygieneKeyForRow(row);
577
- const operation = operationByKey.get(hygieneKey);
578
- if (operation && !ARCHIVE_ACK_STATUSES.has(operation.status)) continue;
579
- const result = await appendCtoEvent(
580
- lakeRoot,
581
- {
582
- event: "hygiene_applied",
583
- now,
584
- source: "tfx_cto_hygiene_apply",
585
- project_root: rootDir,
586
- status: row.status,
587
- hygiene_key: hygieneKey,
588
- hygiene_kind: row.kind,
589
- hygiene_id: row.id,
590
- hygiene_action: row.action || `acknowledge_${row.status}`,
591
- summary: `hygiene apply ${row.kind}:${row.id} ${row.status}`,
592
- actor: hygieneApplyActor(opts),
593
- },
594
- {
595
- stderr: opts.stderr,
596
- lockRetries: opts.ledgerLockRetries,
597
- lockRetryDelayMs: opts.ledgerLockRetryDelayMs,
598
- },
599
- );
600
- if (result.appended) appended.push(result.event);
357
+ if (opts.dryRun !== true && !hasFlag(args, "--dry-run")) {
358
+ throw new Error("tfx cto hygiene requires --dry-run");
601
359
  }
602
- return {
603
- steward: {
604
- lock_path: steward.path,
605
- project_root: steward.project_root,
606
- project_root_hash: steward.project_root_hash,
607
- },
608
- applicable_count: rows.length,
609
- applied_count: appended.length,
610
- actions,
611
- events: appended,
612
- };
613
- }
614
-
615
- export async function runHygiene(args = [], opts = {}) {
616
360
  const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
617
361
  const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
618
362
  const stdout = opts.stdout || process.stdout;
619
- const jsonOut = opts.json === true || hasFlag(args, "--json");
620
- const dryRun = opts.dryRun === true || hasFlag(args, "--dry-run");
621
- const apply = opts.apply === true || hasFlag(args, "--apply");
622
- if (dryRun && apply) {
623
- throw new Error("tfx cto hygiene accepts only one of --dry-run or --apply");
624
- }
625
- if (!dryRun && !apply) {
626
- throw new Error("tfx cto hygiene requires --dry-run or --apply");
627
- }
628
-
629
- const buildProjection = async () => {
630
- const currentPath = join(lakeRoot, "current.json");
631
- const current = existsSync(currentPath) ? readJson(currentPath) : {};
632
- const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
633
- const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
634
- return {
635
- ledger,
636
- overlay,
637
- projection: projectCtoHygiene({
638
- current,
639
- ledger: ledger.length > 0 ? ledger : null,
640
- overlay,
641
- }),
642
- };
643
- };
644
-
645
- let steward = null;
646
- let projection;
647
- let overlay = { live_sessions: [] };
648
- let ledger = [];
649
- let applyResult = null;
650
- if (apply) {
651
- steward = await acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
652
- timeoutMs: opts.stewardLockTimeoutMs,
653
- retryMs: opts.stewardLockRetryMs,
654
- staleMs: opts.stewardLockStaleMs,
655
- lockPath: opts.stewardLockPath,
656
- });
657
- try {
658
- ({ projection, overlay, ledger } = await buildProjection());
659
- applyResult = await applyHygieneRows({
660
- rootDir,
661
- lakeRoot,
662
- projection,
663
- steward,
664
- active: overlay,
665
- approvals: ledger.filter((entry) => entry?.event === "hygiene_applied"),
666
- opts,
667
- });
668
- projection = { ...projection, dry_run: false, apply: applyResult };
669
- } finally {
670
- steward.release();
671
- }
672
- } else {
673
- ({ projection, overlay } = await buildProjection());
674
- projection = {
675
- ...projection,
676
- actions: await applyHygieneArchiveActions({
677
- rootDir,
678
- lakeRoot,
679
- projection,
680
- dryRun: true,
681
- now: opts.now || new Date().toISOString(),
682
- applyMode: opts.applyMode || getCtoHygieneApplyMode(),
683
- active: overlay,
684
- fsOps: opts.fsOps,
685
- }),
686
- };
687
- }
688
-
689
- if (jsonOut) writeJson(stdout, projection);
690
- else {
363
+ const currentPath = join(lakeRoot, "current.json");
364
+ const current = existsSync(currentPath) ? readJson(currentPath) : {};
365
+ const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
366
+ const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
367
+ const projection = projectCtoHygiene({
368
+ current,
369
+ ledger: ledger.length ? ledger : null,
370
+ overlay,
371
+ });
372
+ if (opts.json === true || hasFlag(args, "--json"))
373
+ writeJson(stdout, projection);
374
+ else
691
375
  stdout.write(
692
- apply
693
- ? `cto hygiene apply: ${applyResult.applied_count} event(s) appended for ${applyResult.applicable_count} actionable row(s)\n`
694
- : `cto hygiene dry-run: ${projection.counts.active_tasks} active tasks, ${projection.counts.stale_sessions} stale sessions, ${projection.counts.orphan_worktrees} orphan worktrees\n`,
376
+ `cto hygiene dry-run: ${projection.counts.active_tasks} active tasks, ${projection.counts.stale_sessions} stale sessions, ${projection.counts.orphan_worktrees} orphan worktrees\n`,
695
377
  );
696
- }
697
-
698
378
  return projection;
699
379
  }
package/cto/lake-root.mjs CHANGED
@@ -1,19 +1,12 @@
1
- // cto/lake-root.mjs - resolve the canonical project root for the CTO lake.
2
- //
3
- // CTO lake (.triflux/lake/current.json) 는 repo 루트에 하나만 둔다. 그런데
4
- // runStatus/runCollect/runDashboard 는 process.cwd() 를 기본 rootDir 로 쓰므로,
5
- // repo 루트가 아닌 하위 폴더(예: packages/triflux)에서 `tfx cto` 를 부르면 lake
6
- // 를 못 찾아 "run tfx cto collect" 가 반복되고, collect 는 엉뚱한 하위 폴더에
7
- // 새 .triflux/lake 를 만든다. cwd 에서 `.git` 마커를 위로 탐색해 toplevel 로
8
- // 올려 이 불일치를 없앤다.
9
- //
10
- // git worktree 는 별도 프로젝트가 아니라 같은 프로젝트의 작업 디렉터리다. 따라서
11
- // linked worktree 안에서 호출하더라도 `git rev-parse --git-common-dir` 로 canonical
12
- // project root(일반적으로 main checkout)를 찾아 같은 CTO lake 를 공유한다. git 명령을
13
- // 사용할 수 없으면 기존의 `.git` 상향 탐색으로 fallback 한다.
14
-
15
1
  import { execFileSync as defaultExecFileSync } from "node:child_process";
16
- import { existsSync as defaultExistsSync } from "node:fs";
2
+ import {
3
+ existsSync as defaultExistsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { homedir } from "node:os";
17
10
  import { basename, dirname, join, parse } from "node:path";
18
11
 
19
12
  const MAX_DEPTH = 64;
@@ -66,7 +59,7 @@ export function resolveLakeRootDir(cwd, opts = {}) {
66
59
  const exists = opts?.existsSync || defaultExistsSync;
67
60
  const execFileSync = opts?.execFileSync || defaultExecFileSync;
68
61
  // 비문자열(객체/숫자 등 truthy 포함) 또는 빈 문자열이면 항상 "" 를 반환해
69
- // @returns {string} 계약을 지킨다 — truthy 비문자열을 그대로 누설하지 않는다.
62
+ // @returns {string} 계약을 지킨다. truthy 비문자열을 그대로 누설하지 않는다.
70
63
  if (typeof cwd !== "string" || !cwd) return "";
71
64
 
72
65
  const gitRoot = resolveViaGitCommonDir(cwd, execFileSync);
@@ -83,3 +76,108 @@ export function resolveLakeRootDir(cwd, opts = {}) {
83
76
  }
84
77
  return cwd;
85
78
  }
79
+
80
+ export function firstExisting(paths) {
81
+ return paths.filter(Boolean).find((path) => defaultExistsSync(path)) || null;
82
+ }
83
+
84
+ export function toIsoTime(value) {
85
+ if (typeof value === "string" && value.trim()) return value;
86
+ if (value instanceof Date) return value.toISOString();
87
+ if (typeof value === "number" && Number.isFinite(value)) {
88
+ return new Date(value).toISOString();
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export function shortHash(value) {
94
+ const str = String(value ?? "");
95
+ let h = 5381;
96
+ for (let i = 0; i < str.length; i++) {
97
+ h = (h * 33) ^ str.charCodeAt(i);
98
+ }
99
+ return (h >>> 0).toString(36);
100
+ }
101
+
102
+ export function pathLabel(value) {
103
+ const str = String(value ?? "").replace(/[/\\]+$/u, "");
104
+ if (!str) return "";
105
+ const segments = str.split(/[/\\]+/u);
106
+ return segments[segments.length - 1] || "";
107
+ }
108
+
109
+ export function normalizeLiveSession(session) {
110
+ return {
111
+ sessionId: String(session?.sessionId || session?.session_id || ""),
112
+ agent_id:
113
+ typeof session?.agent_id === "string"
114
+ ? session.agent_id
115
+ : typeof session?.agentId === "string"
116
+ ? session.agentId
117
+ : null,
118
+ phase:
119
+ typeof session?.phase === "string"
120
+ ? session.phase
121
+ : typeof session?.status === "string"
122
+ ? session.status
123
+ : "active",
124
+ started_at: toIsoTime(
125
+ session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
126
+ ),
127
+ };
128
+ }
129
+
130
+ export async function readSynapseSnapshot(opts = {}) {
131
+ const rootDir = opts.rootDir || process.cwd();
132
+ const persistPath = firstExisting([
133
+ opts.synapsePersistPath,
134
+ join(rootDir, ".triflux", "synapse-registry.json"),
135
+ join(rootDir, ".triflux", "synapse", "registry.json"),
136
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-sessions.json"),
137
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
138
+ ]);
139
+ if (!persistPath) return { sessions: [], active_shards: [] };
140
+ const data = JSON.parse(readFileSync(persistPath, "utf8"));
141
+ // 상태 전이는 hub 소유이며 조회는 저장된 live 상태만 읽는다.
142
+ const sessions = Object.entries(data).flatMap(([key, session]) => {
143
+ const sessionId = String(session?.sessionId ?? key).trim();
144
+ if (!sessionId || ["stale", "expired"].includes(session?.status)) return [];
145
+ return [
146
+ {
147
+ ...session,
148
+ sessionId,
149
+ status: session?.status === "idle" ? "idle" : "active",
150
+ lastHeartbeat:
151
+ typeof session?.lastHeartbeat === "number"
152
+ ? session.lastHeartbeat
153
+ : Date.now(),
154
+ },
155
+ ];
156
+ });
157
+ return { sessions, active_shards: [] };
158
+ }
159
+
160
+ export function readJsonLines(filePath, limit = Infinity) {
161
+ if (!defaultExistsSync(filePath)) return [];
162
+ const lines = readFileSync(filePath, "utf8")
163
+ .split(/\r?\n/u)
164
+ .map((line) => line.trim())
165
+ .filter(Boolean);
166
+ return lines.slice(-limit).flatMap((line) => {
167
+ try {
168
+ return [JSON.parse(line)];
169
+ } catch {
170
+ return [];
171
+ }
172
+ });
173
+ }
174
+
175
+ export function writeAtomic(filePath, body) {
176
+ mkdirSync(dirname(filePath), { recursive: true });
177
+ const tmpPath = join(
178
+ dirname(filePath),
179
+ `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
180
+ );
181
+ writeFileSync(tmpPath, body, "utf8");
182
+ renameSync(tmpPath, filePath);
183
+ }