triflux 10.41.0 → 10.42.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,355 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ copyFileSync,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ unlinkSync,
12
+ } from "node:fs";
13
+ import {
14
+ basename,
15
+ dirname,
16
+ isAbsolute,
17
+ join,
18
+ relative,
19
+ resolve,
20
+ } from "node:path";
21
+
22
+ const ARCHIVABLE_ACTIONS = new Set([
23
+ "archive_or_resume_session",
24
+ "prune_superseded_checkpoint",
25
+ ]);
26
+
27
+ function shortHash(value) {
28
+ const str = String(value ?? "");
29
+ let h = 5381;
30
+ for (let i = 0; i < str.length; i += 1) {
31
+ h = (h * 33) ^ str.charCodeAt(i);
32
+ }
33
+ return (h >>> 0).toString(36);
34
+ }
35
+
36
+ function stewardRootHash(rootDir) {
37
+ return createHash("sha256")
38
+ .update(String(rootDir || ""))
39
+ .digest("hex")
40
+ .slice(0, 12);
41
+ }
42
+
43
+ function hygieneKeyForRow(row) {
44
+ return `${row.kind}:${row.id}`;
45
+ }
46
+
47
+ function yyyymmdd(value) {
48
+ const date = value ? new Date(value) : new Date();
49
+ if (Number.isNaN(date.getTime())) return yyyymmdd(new Date().toISOString());
50
+ return date.toISOString().slice(0, 10).replace(/-/gu, "");
51
+ }
52
+
53
+ function safeLabel(value) {
54
+ return (
55
+ String(value || "item")
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9._-]+/gu, "-")
58
+ .replace(/^-+|-+$/gu, "")
59
+ .slice(0, 80) || "item"
60
+ );
61
+ }
62
+
63
+ function isInside(parent, child) {
64
+ const rel = relative(resolve(parent), resolve(child));
65
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
66
+ }
67
+
68
+ function normalizeActiveSessions(active) {
69
+ const sessions = Array.isArray(active)
70
+ ? active
71
+ : Array.isArray(active?.sessions)
72
+ ? active.sessions
73
+ : Array.isArray(active?.live_sessions)
74
+ ? active.live_sessions
75
+ : [];
76
+ return sessions
77
+ .map((session) => ({
78
+ sessionId: String(session?.sessionId || session?.session_id || "").trim(),
79
+ phase: String(
80
+ session?.phase || session?.status || "active",
81
+ ).toLowerCase(),
82
+ }))
83
+ .filter((session) => session.sessionId);
84
+ }
85
+
86
+ function isLiveOwned(row, activeSessions) {
87
+ const rowSession = String(row?.session_id || row?.id || "").trim();
88
+ if (!rowSession) return false;
89
+ return activeSessions.some(
90
+ (session) => session.sessionId === rowSession && session.phase !== "stale",
91
+ );
92
+ }
93
+
94
+ function assertProjectHash(row, rootDir) {
95
+ if (!row?.project_root_hash) return;
96
+ const allowed = new Set([shortHash(rootDir), stewardRootHash(rootDir)]);
97
+ if (!allowed.has(String(row.project_root_hash))) {
98
+ throw new Error(
99
+ `project_root_hash mismatch for ${hygieneKeyForRow(row)}: ${row.project_root_hash}`,
100
+ );
101
+ }
102
+ }
103
+
104
+ function assertLakePath(lakeRoot, path, label) {
105
+ if (!isInside(lakeRoot, path)) {
106
+ throw new Error(`${label} must be under lakeRoot: ${path}`);
107
+ }
108
+ }
109
+
110
+ function sourceBytes(path) {
111
+ const info = statSync(path);
112
+ if (info.isFile()) return info.size;
113
+ if (!info.isDirectory()) return info.size || 0;
114
+ return 0;
115
+ }
116
+
117
+ function targetPathFor({ lakeRoot, row, sourcePath, now }) {
118
+ const archiveDir = join(lakeRoot, "archive", yyyymmdd(now));
119
+ const label = safeLabel(hygieneKeyForRow(row));
120
+ return join(archiveDir, `${label}-${basename(sourcePath)}`);
121
+ }
122
+
123
+ function hasSecondActorApproval(opts = {}) {
124
+ if (opts.humanAck === true || opts.humanGate === true) return true;
125
+ const actorSessionId = String(opts.actorSessionId || "").trim();
126
+ if (!actorSessionId) return false;
127
+ const hygieneKey = opts.hygieneKey;
128
+ const approvals = Array.isArray(opts.approvals) ? opts.approvals : [];
129
+ return approvals.some((entry) => {
130
+ const ref = entry?.ref && typeof entry.ref === "object" ? entry.ref : {};
131
+ if (ref.hygiene_key !== hygieneKey) return false;
132
+ const approverSession = String(ref?.actor?.session_id || "").trim();
133
+ return Boolean(approverSession && approverSession !== actorSessionId);
134
+ });
135
+ }
136
+
137
+ function movePath(sourcePath, targetPath, fsOps = {}) {
138
+ const ops = {
139
+ copyFileSync,
140
+ cpSync,
141
+ existsSync,
142
+ lstatSync,
143
+ mkdirSync,
144
+ renameSync,
145
+ rmSync,
146
+ unlinkSync,
147
+ ...fsOps,
148
+ };
149
+ ops.mkdirSync(dirname(targetPath), { recursive: true });
150
+ try {
151
+ ops.renameSync(sourcePath, targetPath);
152
+ return "rename";
153
+ } catch (error) {
154
+ if (error?.code !== "EXDEV") throw error;
155
+ const info = ops.lstatSync(sourcePath);
156
+ if (info.isDirectory()) {
157
+ ops.cpSync(sourcePath, targetPath, { recursive: true, force: false });
158
+ ops.rmSync(sourcePath, { recursive: true, force: false });
159
+ } else {
160
+ ops.copyFileSync(sourcePath, targetPath);
161
+ ops.unlinkSync(sourcePath);
162
+ }
163
+ return "copy_unlink";
164
+ }
165
+ }
166
+
167
+ function digestOperations(operations) {
168
+ const payload = operations.map((operation) => ({
169
+ hygiene_key: operation.hygiene_key,
170
+ status: operation.status,
171
+ source_path: operation.source_path,
172
+ target_path: operation.target_path,
173
+ bytes: operation.bytes,
174
+ reason: operation.reason,
175
+ }));
176
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
177
+ }
178
+
179
+ function rowsFromProjection(projection) {
180
+ return Array.isArray(projection?.rows) ? projection.rows : [];
181
+ }
182
+
183
+ export async function applyHygieneArchiveActions({
184
+ rootDir,
185
+ lakeRoot,
186
+ projection,
187
+ dryRun = true,
188
+ now = new Date().toISOString(),
189
+ applyMode = "off",
190
+ active = {},
191
+ humanAck = false,
192
+ humanGate = false,
193
+ actorSessionId = null,
194
+ approvals = [],
195
+ fsOps = {},
196
+ } = {}) {
197
+ const archiveRoot = join(lakeRoot, "archive", yyyymmdd(now));
198
+ assertLakePath(lakeRoot, archiveRoot, "archive target");
199
+
200
+ const activeSessions = normalizeActiveSessions(active);
201
+ const operations = [];
202
+
203
+ for (const row of rowsFromProjection(projection)) {
204
+ const hygieneKey = hygieneKeyForRow(row);
205
+ if (row?.kind === "worktree" && row?.status === "orphaned") {
206
+ operations.push({
207
+ hygiene_key: hygieneKey,
208
+ kind: row.kind,
209
+ id: row.id,
210
+ action: row.action,
211
+ status: "blocked",
212
+ reason: "orphan_worktree_requires_human",
213
+ bytes: 0,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ if (!ARCHIVABLE_ACTIONS.has(row?.action)) continue;
219
+
220
+ assertProjectHash(row, rootDir);
221
+
222
+ if (isLiveOwned(row, activeSessions)) {
223
+ operations.push({
224
+ hygiene_key: hygieneKey,
225
+ kind: row.kind,
226
+ id: row.id,
227
+ action: row.action,
228
+ status: "skipped",
229
+ reason: "live_session_owned",
230
+ bytes: 0,
231
+ });
232
+ continue;
233
+ }
234
+
235
+ const sourcePath = row.artifact_path ? resolve(row.artifact_path) : null;
236
+ if (!sourcePath) {
237
+ operations.push({
238
+ hygiene_key: hygieneKey,
239
+ kind: row.kind,
240
+ id: row.id,
241
+ action: row.action,
242
+ status: "skipped",
243
+ reason: "missing_artifact_path",
244
+ bytes: 0,
245
+ });
246
+ continue;
247
+ }
248
+ // lake 밖 산출물은 자동 이동 대상이 아니다 — 크래시 대신 skip으로 보고한다.
249
+ // (checkpoint 등 실제 산출물은 대부분 lake 밖에 있으므로 throw 하면 apply 전체가 죽는다.)
250
+ if (!isInside(lakeRoot, sourcePath)) {
251
+ operations.push({
252
+ hygiene_key: hygieneKey,
253
+ kind: row.kind,
254
+ id: row.id,
255
+ action: row.action,
256
+ source_path: sourcePath,
257
+ status: "skipped",
258
+ reason: "source_outside_lake",
259
+ bytes: 0,
260
+ });
261
+ continue;
262
+ }
263
+
264
+ const targetPath = targetPathFor({ lakeRoot, row, sourcePath, now });
265
+ assertLakePath(lakeRoot, targetPath, "archive target");
266
+
267
+ if (!existsSync(sourcePath)) {
268
+ operations.push({
269
+ hygiene_key: hygieneKey,
270
+ kind: row.kind,
271
+ id: row.id,
272
+ action: row.action,
273
+ source_path: sourcePath,
274
+ target_path: targetPath,
275
+ status: existsSync(targetPath) ? "already_archived" : "skipped",
276
+ reason: existsSync(targetPath) ? undefined : "source_missing",
277
+ bytes: 0,
278
+ });
279
+ continue;
280
+ }
281
+
282
+ operations.push({
283
+ hygiene_key: hygieneKey,
284
+ kind: row.kind,
285
+ id: row.id,
286
+ action: row.action,
287
+ source_path: sourcePath,
288
+ target_path: targetPath,
289
+ status: "planned",
290
+ bytes: sourceBytes(sourcePath),
291
+ });
292
+ }
293
+
294
+ if (!dryRun) {
295
+ for (const operation of operations) {
296
+ if (operation.status !== "planned") continue;
297
+ if (applyMode !== "archive") {
298
+ operation.status = "blocked";
299
+ operation.reason = "apply_mode_off";
300
+ continue;
301
+ }
302
+ if (
303
+ !hasSecondActorApproval({
304
+ humanAck,
305
+ humanGate,
306
+ actorSessionId,
307
+ approvals,
308
+ hygieneKey: operation.hygiene_key,
309
+ })
310
+ ) {
311
+ operation.status = "blocked";
312
+ operation.reason = "second_actor_ack_required";
313
+ continue;
314
+ }
315
+ operation.move_method = movePath(
316
+ operation.source_path,
317
+ operation.target_path,
318
+ fsOps,
319
+ );
320
+ operation.status = "archived";
321
+ }
322
+ }
323
+
324
+ const plannedCount = operations.filter((operation) =>
325
+ ["planned", "archived", "already_archived"].includes(operation.status),
326
+ ).length;
327
+ const appliedCount = operations.filter(
328
+ (operation) => operation.status === "archived",
329
+ ).length;
330
+ const blockedCount = operations.filter(
331
+ (operation) => operation.status === "blocked",
332
+ ).length;
333
+
334
+ return {
335
+ schema_version: "cto-hygiene-actions.v1",
336
+ dry_run: Boolean(dryRun),
337
+ apply_mode: applyMode,
338
+ archive_root: archiveRoot,
339
+ planned_count: plannedCount,
340
+ applied_count: appliedCount,
341
+ blocked_count: blockedCount,
342
+ total_bytes: operations.reduce(
343
+ (sum, operation) => sum + Number(operation.bytes || 0),
344
+ 0,
345
+ ),
346
+ digest: digestOperations(operations),
347
+ live_safety: {
348
+ active_session_count: activeSessions.length,
349
+ skipped_live_count: operations.filter(
350
+ (operation) => operation.reason === "live_session_owned",
351
+ ).length,
352
+ },
353
+ operations,
354
+ };
355
+ }
package/cto/hygiene.mjs CHANGED
@@ -11,8 +11,9 @@ import {
11
11
  } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, dirname, join } from "node:path";
14
-
14
+ import { getCtoHygieneApplyMode } from "../hub/lib/cto-env.mjs";
15
15
  import { appendCtoEvent } from "./events.mjs";
16
+ import { applyHygieneArchiveActions } from "./hygiene-actions.mjs";
16
17
  import { resolveLakeRootDir } from "./lake-root.mjs";
17
18
 
18
19
  const COUNT_KEYS = [
@@ -178,6 +179,18 @@ function rowFrom(kind, id, status, entry, ref, action) {
178
179
  if (entry?.summary) row.summary = String(entry.summary);
179
180
  if (action) row.action = action;
180
181
  if (action && !hasKnownOwner(ref)) row.owner = "unknown";
182
+ if (typeof ref?.artifact_path === "string" && ref.artifact_path.trim()) {
183
+ row.artifact_path = ref.artifact_path.trim();
184
+ }
185
+ if (
186
+ typeof ref?.project_root_hash === "string" &&
187
+ ref.project_root_hash.trim()
188
+ ) {
189
+ row.project_root_hash = ref.project_root_hash.trim();
190
+ }
191
+ if (typeof ref?.session_id === "string" && ref.session_id.trim()) {
192
+ row.session_id = ref.session_id.trim();
193
+ }
181
194
  return row;
182
195
  }
183
196
 
@@ -517,17 +530,52 @@ function isApplicableRow(row) {
517
530
  return Boolean(row?.action) || APPLICABLE_STATUSES.has(row?.status);
518
531
  }
519
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();
539
+ }
540
+ return actor;
541
+ }
542
+
520
543
  async function applyHygieneRows({
521
544
  rootDir,
522
545
  lakeRoot,
523
546
  projection,
524
547
  steward,
548
+ active,
549
+ approvals,
525
550
  opts,
526
551
  }) {
527
552
  const rows = projection.rows.filter(isApplicableRow);
528
553
  const appended = [];
529
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
+ );
530
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;
531
579
  const result = await appendCtoEvent(
532
580
  lakeRoot,
533
581
  {
@@ -536,12 +584,12 @@ async function applyHygieneRows({
536
584
  source: "tfx_cto_hygiene_apply",
537
585
  project_root: rootDir,
538
586
  status: row.status,
539
- hygiene_key: hygieneKeyForRow(row),
587
+ hygiene_key: hygieneKey,
540
588
  hygiene_kind: row.kind,
541
589
  hygiene_id: row.id,
542
590
  hygiene_action: row.action || `acknowledge_${row.status}`,
543
591
  summary: `hygiene apply ${row.kind}:${row.id} ${row.status}`,
544
- actor: { cli: "tfx cto hygiene --apply" },
592
+ actor: hygieneApplyActor(opts),
545
593
  },
546
594
  {
547
595
  stderr: opts.stderr,
@@ -559,6 +607,7 @@ async function applyHygieneRows({
559
607
  },
560
608
  applicable_count: rows.length,
561
609
  applied_count: appended.length,
610
+ actions,
562
611
  events: appended,
563
612
  };
564
613
  }
@@ -582,15 +631,21 @@ export async function runHygiene(args = [], opts = {}) {
582
631
  const current = existsSync(currentPath) ? readJson(currentPath) : {};
583
632
  const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
584
633
  const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
585
- return projectCtoHygiene({
586
- current,
587
- ledger: ledger.length > 0 ? ledger : null,
634
+ return {
635
+ ledger,
588
636
  overlay,
589
- });
637
+ projection: projectCtoHygiene({
638
+ current,
639
+ ledger: ledger.length > 0 ? ledger : null,
640
+ overlay,
641
+ }),
642
+ };
590
643
  };
591
644
 
592
645
  let steward = null;
593
646
  let projection;
647
+ let overlay = { live_sessions: [] };
648
+ let ledger = [];
594
649
  let applyResult = null;
595
650
  if (apply) {
596
651
  steward = await acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
@@ -600,12 +655,14 @@ export async function runHygiene(args = [], opts = {}) {
600
655
  lockPath: opts.stewardLockPath,
601
656
  });
602
657
  try {
603
- projection = await buildProjection();
658
+ ({ projection, overlay, ledger } = await buildProjection());
604
659
  applyResult = await applyHygieneRows({
605
660
  rootDir,
606
661
  lakeRoot,
607
662
  projection,
608
663
  steward,
664
+ active: overlay,
665
+ approvals: ledger.filter((entry) => entry?.event === "hygiene_applied"),
609
666
  opts,
610
667
  });
611
668
  projection = { ...projection, dry_run: false, apply: applyResult };
@@ -613,7 +670,20 @@ export async function runHygiene(args = [], opts = {}) {
613
670
  steward.release();
614
671
  }
615
672
  } else {
616
- projection = await buildProjection();
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
+ };
617
687
  }
618
688
 
619
689
  if (jsonOut) writeJson(stdout, projection);
package/cto/index.mjs CHANGED
@@ -1,4 +1,11 @@
1
- const SUBCOMMANDS = ["collect", "status", "dashboard", "hygiene", "event"];
1
+ const SUBCOMMANDS = [
2
+ "collect",
3
+ "status",
4
+ "dashboard",
5
+ "hygiene",
6
+ "steward",
7
+ "event",
8
+ ];
2
9
 
3
10
  function printUsage(subcommand) {
4
11
  if (subcommand) {
@@ -6,13 +13,14 @@ function printUsage(subcommand) {
6
13
  }
7
14
  console.log(`
8
15
  Usage
9
- tfx cto <collect|status|dashboard|hygiene|event> [options]
16
+ tfx cto <collect|status|dashboard|hygiene|steward|event> [options]
10
17
 
11
18
  Subcommands
12
19
  collect Refresh .triflux/lake/current.json from repo-local authority sources
13
20
  status Print the current authority summary
14
21
  dashboard Render the CTO console dashboard, optionally with --watch
15
22
  hygiene Project CTO hygiene counts and actionable dry-run rows
23
+ steward Periodically collect and run one-shot CTO hygiene (TFX_CTO=0 disables)
16
24
  event Append normalized wrapper lifecycle events to the CTO ledger
17
25
  `);
18
26
  }
@@ -37,6 +45,10 @@ export async function cmdCto(cmdArgs, opts = {}) {
37
45
  const { runHygiene } = await import("./hygiene.mjs");
38
46
  return runHygiene(rest, opts);
39
47
  }
48
+ case "steward": {
49
+ const { runSteward } = await import("./steward.mjs");
50
+ return runSteward(rest, opts);
51
+ }
40
52
  case "event": {
41
53
  const { runEvent } = await import("./events.mjs");
42
54
  return runEvent(rest, opts);