@triflux/remote 10.41.1 → 10.43.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,362 @@
1
+ // cto/steward.mjs — collect + hygiene 주기 루프 (resident-cto-manager PRD T2 선행 슬라이스)
2
+ // 가드레일: TFX_CTO kill-switch 매 cycle 확인 + watch 루프는 lake당 단일 인스턴스 락.
3
+ import { utimesSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ import { isCtoDisabled } from "@triflux/core/hub/lib/cto-env.mjs";
7
+ import { runCollect } from "./collect.mjs";
8
+ import { acquireCtoHygieneStewardLock, runHygiene } from "./hygiene.mjs";
9
+ import { resolveLakeRootDir } from "./lake-root.mjs";
10
+
11
+ const SCHEMA_VERSION = "cto-steward.v1";
12
+ const DEFAULT_INTERVAL_MS = 60_000;
13
+ const LOOP_LOCK_STALE_MS = 10 * 60_000;
14
+ const KNOWN_FLAGS_WITH_VALUES = new Set(["--max-runs", "--interval-ms"]);
15
+ const KNOWN_BOOLEAN_FLAGS = new Set([
16
+ "--watch",
17
+ "--apply",
18
+ "--dry-run",
19
+ "--json",
20
+ "--no-collect",
21
+ ]);
22
+
23
+ function writeJson(stdout, payload) {
24
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
25
+ }
26
+
27
+ function parsePositiveInteger(name, value) {
28
+ if (value === undefined || String(value).startsWith("--")) {
29
+ throw new Error(`tfx cto steward ${name} requires a value`);
30
+ }
31
+ if (!/^\d+$/u.test(String(value))) {
32
+ throw new Error(`tfx cto steward ${name} must be a positive integer`);
33
+ }
34
+ const parsed = Number(value);
35
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
36
+ throw new Error(`tfx cto steward ${name} must be a positive integer`);
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ function parseNonNegativeInteger(name, value) {
42
+ if (value === undefined || String(value).startsWith("--")) {
43
+ throw new Error(`tfx cto steward ${name} requires a value`);
44
+ }
45
+ if (!/^\d+$/u.test(String(value))) {
46
+ throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
47
+ }
48
+ const parsed = Number(value);
49
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
50
+ throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
51
+ }
52
+ return parsed;
53
+ }
54
+
55
+ export function parseStewardArgs(args = []) {
56
+ const parsed = {
57
+ watch: false,
58
+ maxRuns: null,
59
+ intervalMs: DEFAULT_INTERVAL_MS,
60
+ apply: false,
61
+ dryRun: false,
62
+ json: false,
63
+ collectEnabled: true,
64
+ };
65
+
66
+ for (let index = 0; index < args.length; index += 1) {
67
+ const arg = args[index];
68
+ if (KNOWN_FLAGS_WITH_VALUES.has(arg)) {
69
+ const value = args[index + 1];
70
+ if (arg === "--max-runs")
71
+ parsed.maxRuns = parsePositiveInteger(arg, value);
72
+ else parsed.intervalMs = parseNonNegativeInteger(arg, value);
73
+ index += 1;
74
+ continue;
75
+ }
76
+ if (KNOWN_BOOLEAN_FLAGS.has(arg)) {
77
+ if (arg === "--watch") parsed.watch = true;
78
+ else if (arg === "--apply") parsed.apply = true;
79
+ else if (arg === "--dry-run") parsed.dryRun = true;
80
+ else if (arg === "--json") parsed.json = true;
81
+ else if (arg === "--no-collect") parsed.collectEnabled = false;
82
+ continue;
83
+ }
84
+ throw new Error(`unknown tfx cto steward flag: ${arg}`);
85
+ }
86
+
87
+ if (parsed.apply && parsed.dryRun) {
88
+ throw new Error("tfx cto steward accepts only one of --apply or --dry-run");
89
+ }
90
+ return parsed;
91
+ }
92
+
93
+ function createSilentStdout() {
94
+ return {
95
+ write() {
96
+ return true;
97
+ },
98
+ };
99
+ }
100
+
101
+ function compactRun(index, collectResult, hygieneResult) {
102
+ return {
103
+ index,
104
+ collected: collectResult !== null,
105
+ collect: collectResult,
106
+ hygiene: hygieneResult,
107
+ };
108
+ }
109
+
110
+ function isAbortError(error) {
111
+ return error?.name === "AbortError" || error?.code === "ABORT_ERR";
112
+ }
113
+
114
+ export function stewardLoopLockPath(lakeRoot) {
115
+ return join(lakeRoot, "stewards", "steward-loop.lock");
116
+ }
117
+
118
+ export async function runSteward(args = [], opts = {}) {
119
+ const parsed = parseStewardArgs(args);
120
+ const jsonRequested = parsed.json || opts.json === true;
121
+ if (parsed.watch && parsed.maxRuns === null && jsonRequested) {
122
+ throw new Error(
123
+ "tfx cto steward --watch --json requires --max-runs for a finite JSON summary",
124
+ );
125
+ }
126
+ if (!parsed.watch && parsed.maxRuns !== null) {
127
+ (opts.stderr || process.stderr).write(
128
+ "tfx cto steward: --max-runs is ignored without --watch\n",
129
+ );
130
+ }
131
+
132
+ // steward 전용 opts 는 여기서 걸러내고, 나머지(overlay, synapseReader 등)만
133
+ // collect/hygiene 로 전달한다.
134
+ const {
135
+ collectFn: injectedCollectFn,
136
+ hygieneFn: injectedHygieneFn,
137
+ sleepFn: injectedSleepFn,
138
+ onRun,
139
+ json: _jsonOpt,
140
+ stdout: injectedStdout,
141
+ stderr: _stderrOpt,
142
+ signal: externalSignal,
143
+ isCtoDisabledFn: injectedGate,
144
+ acquireLoopLockFn: injectedAcquireLoopLock,
145
+ installSignalHandler,
146
+ rootDir: _rootDirOpt,
147
+ lakeRoot: _lakeRootOpt,
148
+ ...innerOpts
149
+ } = opts;
150
+
151
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
152
+ const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
153
+ const stdout = injectedStdout || process.stdout;
154
+ const collectFn = injectedCollectFn || runCollect;
155
+ const hygieneFn = injectedHygieneFn || runHygiene;
156
+ const ctoDisabled = injectedGate || isCtoDisabled;
157
+
158
+ const controller = new AbortController();
159
+ const signal = controller.signal;
160
+ if (externalSignal) {
161
+ if (externalSignal.aborted) controller.abort();
162
+ else
163
+ externalSignal.addEventListener("abort", () => controller.abort(), {
164
+ once: true,
165
+ });
166
+ }
167
+
168
+ const sleepFn =
169
+ injectedSleepFn ||
170
+ ((ms) =>
171
+ new Promise((resolve) => {
172
+ if (signal.aborted) {
173
+ resolve();
174
+ return;
175
+ }
176
+ const timer = setTimeout(resolve, ms);
177
+ signal.addEventListener(
178
+ "abort",
179
+ () => {
180
+ clearTimeout(timer);
181
+ resolve();
182
+ },
183
+ { once: true },
184
+ );
185
+ }));
186
+
187
+ const mode = parsed.apply ? "apply" : "dry-run";
188
+ const silentStdout = createSilentStdout();
189
+ const runLimit = parsed.watch ? parsed.maxRuns : 1;
190
+ const retainRuns = runLimit !== null;
191
+ const runs = [];
192
+ let latestRun = null;
193
+ let lastCollect = null;
194
+ let lastHygiene = null;
195
+ let runCount = 0;
196
+ let stoppedReason = null;
197
+
198
+ // 가드레일 1 (PRD kill-switch): 루프 진입 전에도, 매 cycle에도 확인한다.
199
+ if (ctoDisabled()) stoppedReason = "disabled";
200
+
201
+ // 가드레일 2 (PRD single-instance): watch 루프는 lake당 하나만 돈다.
202
+ const loopLockPath = stewardLoopLockPath(lakeRoot);
203
+ const loopLockStaleMs = Math.max(LOOP_LOCK_STALE_MS, parsed.intervalMs * 3);
204
+ let loopLock = null;
205
+ let loopLockHeartbeat = null;
206
+ if (!stoppedReason && parsed.watch) {
207
+ const acquireLock =
208
+ injectedAcquireLoopLock ||
209
+ ((lockPath) =>
210
+ acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
211
+ lockPath,
212
+ timeoutMs: 0,
213
+ staleMs: loopLockStaleMs,
214
+ }));
215
+ loopLock = await acquireLock(loopLockPath);
216
+ // 한 cycle이 staleMs보다 길어져도 다른 인스턴스가 락을 stale로 오판해
217
+ // 훔치지 못하도록, 작업 진행 중에도 주기적으로 mtime을 갱신한다.
218
+ if (loopLock?.path) {
219
+ const heartbeatMs = Math.max(30_000, Math.floor(loopLockStaleMs / 3));
220
+ loopLockHeartbeat = setInterval(() => {
221
+ try {
222
+ const touch = new Date();
223
+ utimesSync(loopLock.path, touch, touch);
224
+ } catch {}
225
+ }, heartbeatMs);
226
+ loopLockHeartbeat.unref?.();
227
+ }
228
+ }
229
+
230
+ let sigintHandler = null;
231
+ if (!stoppedReason && parsed.watch && installSignalHandler !== false) {
232
+ sigintHandler = () => controller.abort();
233
+ process.once("SIGINT", sigintHandler);
234
+ }
235
+
236
+ try {
237
+ while (!stoppedReason && (runLimit === null || runCount < runLimit)) {
238
+ if (ctoDisabled()) {
239
+ stoppedReason = "disabled";
240
+ break;
241
+ }
242
+ if (signal.aborted) {
243
+ stoppedReason = "signal";
244
+ break;
245
+ }
246
+
247
+ const runIndex = runCount + 1;
248
+ let collectResult = null;
249
+
250
+ if (parsed.collectEnabled) {
251
+ collectResult = await collectFn([], {
252
+ ...innerOpts,
253
+ rootDir,
254
+ lakeRoot,
255
+ stdout: silentStdout,
256
+ });
257
+ lastCollect = collectResult;
258
+ }
259
+
260
+ // collect 도중 중단/kill-switch가 들어왔으면 이번 cycle의 hygiene(특히
261
+ // apply)을 시작하지 않는다 — 중단 요청 이후의 fs-mutation 금지.
262
+ if (ctoDisabled()) {
263
+ stoppedReason = "disabled";
264
+ break;
265
+ }
266
+ if (signal.aborted) {
267
+ stoppedReason = "signal";
268
+ break;
269
+ }
270
+
271
+ const hygieneArgs = parsed.apply
272
+ ? ["--apply", "--json"]
273
+ : ["--dry-run", "--json"];
274
+ const hygieneResult = await hygieneFn(hygieneArgs, {
275
+ ...innerOpts,
276
+ rootDir,
277
+ lakeRoot,
278
+ stdout: silentStdout,
279
+ apply: parsed.apply,
280
+ dryRun: !parsed.apply,
281
+ json: true,
282
+ });
283
+ lastHygiene = hygieneResult;
284
+
285
+ const run = compactRun(runIndex, collectResult, hygieneResult);
286
+ latestRun = run;
287
+ if (retainRuns) runs.push(run);
288
+ runCount += 1;
289
+
290
+ if (parsed.watch && !jsonRequested) {
291
+ stdout.write(
292
+ `cto steward run ${runIndex} (${mode}): collect=${
293
+ collectResult !== null ? "ok" : "skipped"
294
+ } hygiene=ok\n`,
295
+ );
296
+ }
297
+ if (typeof onRun === "function") {
298
+ await onRun(run, {
299
+ run_count: runCount,
300
+ rootDir,
301
+ lakeRoot,
302
+ mode,
303
+ });
304
+ }
305
+
306
+ if (!parsed.watch) break;
307
+ if (runLimit !== null && runCount >= runLimit) break;
308
+ try {
309
+ await sleepFn(parsed.intervalMs);
310
+ } catch (error) {
311
+ if (isAbortError(error)) {
312
+ stoppedReason = "signal";
313
+ break;
314
+ }
315
+ throw error;
316
+ }
317
+ if (signal.aborted) {
318
+ stoppedReason = "signal";
319
+ break;
320
+ }
321
+ }
322
+ } finally {
323
+ if (sigintHandler) process.off("SIGINT", sigintHandler);
324
+ if (loopLockHeartbeat) clearInterval(loopLockHeartbeat);
325
+ loopLock?.release?.();
326
+ }
327
+
328
+ if (!stoppedReason) stoppedReason = parsed.watch ? "max-runs" : "single-run";
329
+
330
+ const summary = {
331
+ schema_version: SCHEMA_VERSION,
332
+ mode,
333
+ watch: parsed.watch,
334
+ interval_ms: parsed.intervalMs,
335
+ max_runs: parsed.maxRuns,
336
+ collect_enabled: parsed.collectEnabled,
337
+ disabled: stoppedReason === "disabled",
338
+ stopped_reason: stoppedReason,
339
+ loop_lock_path: parsed.watch ? loopLockPath : null,
340
+ run_count: runCount,
341
+ retained_run_count: runs.length,
342
+ runs,
343
+ latest_run: latestRun,
344
+ last_collect: lastCollect,
345
+ last_hygiene: lastHygiene,
346
+ };
347
+
348
+ if (jsonRequested) writeJson(stdout, summary);
349
+ else if (stoppedReason === "disabled") {
350
+ stdout.write(
351
+ `cto steward disabled by TFX_CTO kill-switch after ${runCount} run(s)\n`,
352
+ );
353
+ } else {
354
+ stdout.write(
355
+ `cto steward ${mode}: ${runCount} run(s), collect ${
356
+ parsed.collectEnabled ? "enabled" : "disabled"
357
+ }, stopped=${stoppedReason}\n`,
358
+ );
359
+ }
360
+
361
+ return summary;
362
+ }