@triflux/core 10.1.0 → 10.25.1
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/hooks/agent-route-guard.mjs +14 -10
- package/hooks/cross-review-tracker.mjs +21 -8
- package/hooks/error-context.mjs +20 -8
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +267 -35
- package/hooks/hook-registry.json +343 -256
- package/hooks/hooks.json +49 -1
- package/hooks/keyword-rules.json +183 -23
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hooks/mcp-config-watcher.mjs +6 -2
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +369 -22
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/safety-guard.mjs +254 -33
- package/hooks/session-end-cleanup.mjs +345 -0
- package/hooks/session-start-fast.mjs +214 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +9 -4
- package/hub/account-broker.mjs +1036 -66
- package/hub/adaptive-diagnostic.mjs +75 -48
- package/hub/adaptive-inject.mjs +96 -58
- package/hub/adaptive-memory.mjs +156 -42
- package/hub/adaptive.mjs +60 -31
- package/hub/assign-callbacks.mjs +163 -126
- package/hub/bridge.mjs +831 -290
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -4
- package/hub/codex-preflight.mjs +113 -37
- package/hub/delegator/contracts.mjs +19 -23
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/service.mjs +88 -64
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/fullcycle.mjs +34 -18
- package/hub/gemini-adapter.mjs +77 -95
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +167 -38
- package/hub/lib/bash-path.mjs +73 -0
- package/hub/lib/cache-guard.mjs +43 -17
- package/hub/lib/env-detect.mjs +145 -0
- package/hub/lib/hosts-compat.mjs +319 -0
- package/hub/lib/mcp-response-cache.mjs +239 -0
- package/hub/lib/memory-store.mjs +285 -111
- package/hub/lib/path-utils.mjs +165 -0
- package/hub/lib/phase-manager.mjs +493 -0
- package/hub/lib/process-utils.mjs +797 -104
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/risk-tier.mjs +53 -0
- package/hub/lib/spawn-trace.mjs +561 -0
- package/hub/lib/ssh-command.mjs +44 -24
- package/hub/lib/ssh-retry.mjs +5 -1
- package/hub/lib/staged-file-detect.mjs +43 -0
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/lib/tfx-route-args.mjs +309 -0
- package/hub/lib/timeline-adapter.mjs +190 -0
- package/hub/lib/trace-recorder.mjs +153 -0
- package/hub/lib/uuidv7.mjs +4 -3
- package/hub/memory-doctor.mjs +266 -106
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/middleware/request-logger.mjs +61 -34
- package/hub/paths.mjs +9 -9
- package/hub/pipeline/gates/confidence.mjs +34 -15
- package/hub/pipeline/gates/consensus.mjs +27 -15
- package/hub/pipeline/gates/index.mjs +7 -3
- package/hub/pipeline/gates/selfcheck.mjs +57 -19
- package/hub/pipeline/index.mjs +77 -42
- package/hub/pipeline/state.mjs +10 -10
- package/hub/pipeline/transitions.mjs +40 -23
- package/hub/platform.mjs +65 -48
- package/hub/quality/deslop.mjs +70 -49
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +33 -25
- package/hub/router.mjs +941 -791
- package/hub/routing/complexity.mjs +132 -29
- package/hub/routing/index.mjs +17 -12
- package/hub/routing/q-learning.mjs +76 -28
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/session-fingerprint.mjs +127 -61
- package/hub/state.mjs +86 -44
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/token-mode.mjs +72 -71
- package/hub/workers/worker-utils.mjs +22 -16
- package/hud/colors.mjs +27 -9
- package/hud/constants.mjs +184 -27
- package/hud/context-monitor.mjs +133 -44
- package/hud/hud-qos-status.mjs +153 -50
- package/hud/mission-board.mjs +56 -0
- package/hud/providers/claude.mjs +398 -114
- package/hud/providers/codex.mjs +96 -21
- package/hud/providers/gemini.mjs +184 -51
- package/hud/renderers.mjs +413 -123
- package/hud/terminal.mjs +101 -31
- package/hud/utils.mjs +81 -39
- package/mesh/index.mjs +69 -0
- package/mesh/mesh-budget.mjs +137 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +163 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +77 -0
- package/package.json +5 -1
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +6 -1
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +3 -3
- package/scripts/lib/cross-review-utils.mjs +13 -5
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +58 -30
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +44 -10
- package/scripts/lib/handoff.mjs +33 -17
- package/scripts/lib/hook-utils.mjs +8 -6
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +48 -19
- package/scripts/lib/logger.mjs +24 -24
- package/scripts/lib/mcp-filter.mjs +391 -247
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +1281 -115
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +24 -14
- package/scripts/lib/mcp-server-catalog.mjs +355 -118
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +70 -10
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +44 -14
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +32 -7
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/lib/ssh-command.mjs
CHANGED
|
@@ -2,26 +2,21 @@
|
|
|
2
2
|
// PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
|
|
3
3
|
// 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { join } from "node:path";
|
|
5
|
+
import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
|
|
7
6
|
|
|
8
7
|
/** hosts.json 캐시 (프로세스 수명 동안 유지) */
|
|
9
8
|
let hostsCache = null;
|
|
9
|
+
let hostsCacheRoot = null;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* hosts.json을 로드하여 캐시한다.
|
|
13
13
|
* @param {string} [repoRoot] — 프로젝트 루트 경로
|
|
14
14
|
*/
|
|
15
15
|
function loadHostsCache(repoRoot) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
: join(process.cwd(), "references", "hosts.json");
|
|
21
|
-
hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
22
|
-
} catch {
|
|
23
|
-
hostsCache = { hosts: {} };
|
|
24
|
-
}
|
|
16
|
+
const cacheKey = repoRoot || process.cwd();
|
|
17
|
+
if (hostsCache && hostsCacheRoot === cacheKey) return;
|
|
18
|
+
hostsCache = readHosts(repoRoot);
|
|
19
|
+
hostsCacheRoot = cacheKey;
|
|
25
20
|
}
|
|
26
21
|
|
|
27
22
|
/**
|
|
@@ -33,7 +28,7 @@ function loadHostsCache(repoRoot) {
|
|
|
33
28
|
export function detectHostOs(hostAlias, repoRoot) {
|
|
34
29
|
loadHostsCache(repoRoot);
|
|
35
30
|
|
|
36
|
-
const hostCfg =
|
|
31
|
+
const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
|
|
37
32
|
if (hostCfg?.os === "windows") return "windows";
|
|
38
33
|
if (hostCfg?.os) return "posix";
|
|
39
34
|
|
|
@@ -112,8 +107,10 @@ export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
|
112
107
|
const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
|
|
113
108
|
|
|
114
109
|
const sshArgs = [
|
|
115
|
-
"-o",
|
|
116
|
-
|
|
110
|
+
"-o",
|
|
111
|
+
`ConnectTimeout=${connectTimeout}`,
|
|
112
|
+
"-o",
|
|
113
|
+
"BatchMode=yes",
|
|
117
114
|
];
|
|
118
115
|
|
|
119
116
|
if (keepalive) {
|
|
@@ -166,7 +163,7 @@ export function validateCommandForOs(command, os) {
|
|
|
166
163
|
*/
|
|
167
164
|
export function getHostConfig(hostAlias, repoRoot) {
|
|
168
165
|
loadHostsCache(repoRoot);
|
|
169
|
-
return
|
|
166
|
+
return readHost(hostAlias, repoRoot);
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
/**
|
|
@@ -178,20 +175,15 @@ export function getHostConfig(hostAlias, repoRoot) {
|
|
|
178
175
|
export function resolveHostAlias(alias, repoRoot) {
|
|
179
176
|
loadHostsCache(repoRoot);
|
|
180
177
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
184
|
-
if (cfg.aliases?.includes(alias)) return name;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return null;
|
|
178
|
+
return resolveHost(alias, repoRoot)?.name ?? null;
|
|
188
179
|
}
|
|
189
180
|
|
|
190
181
|
/**
|
|
191
182
|
* 특정 capability를 보유한 호스트 목록을 반환한다.
|
|
183
|
+
* 명시적 선택 UI에서 바로 리소스 정보를 보여줄 수 있도록 specs를 평탄화한다.
|
|
192
184
|
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
193
185
|
* @param {string} [repoRoot]
|
|
194
|
-
* @returns {Array<{ name: string, config: object }>}
|
|
186
|
+
* @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
|
|
195
187
|
*/
|
|
196
188
|
export function selectHostForCapability(capability, repoRoot) {
|
|
197
189
|
loadHostsCache(repoRoot);
|
|
@@ -199,13 +191,41 @@ export function selectHostForCapability(capability, repoRoot) {
|
|
|
199
191
|
const matches = [];
|
|
200
192
|
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
201
193
|
if (cfg.capabilities?.includes(capability)) {
|
|
202
|
-
matches.push({
|
|
194
|
+
matches.push({
|
|
195
|
+
name,
|
|
196
|
+
config: cfg,
|
|
197
|
+
specs: {
|
|
198
|
+
cores: cfg.specs?.cores,
|
|
199
|
+
ram_gb: cfg.specs?.ram_gb,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
203
202
|
}
|
|
204
203
|
}
|
|
205
204
|
return matches;
|
|
206
205
|
}
|
|
207
206
|
|
|
207
|
+
/**
|
|
208
|
+
* 특정 capability를 가진 호스트를 리소스 기준으로 정렬해 반환한다.
|
|
209
|
+
* 자동 선택은 하지 않고, 명시적 선택 목록 정렬에만 사용한다.
|
|
210
|
+
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
211
|
+
* @param {"cores"|"ram_gb"} sortBy
|
|
212
|
+
* @param {string} [repoRoot]
|
|
213
|
+
* @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
|
|
214
|
+
*/
|
|
215
|
+
export function selectHostByResources(capability, sortBy, repoRoot) {
|
|
216
|
+
if (sortBy !== "cores" && sortBy !== "ram_gb") {
|
|
217
|
+
throw new TypeError('sortBy must be "cores" or "ram_gb"');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return selectHostForCapability(capability, repoRoot).sort((a, b) => {
|
|
221
|
+
const aValue = a.specs?.[sortBy] ?? -Infinity;
|
|
222
|
+
const bValue = b.specs?.[sortBy] ?? -Infinity;
|
|
223
|
+
return bValue - aValue;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
208
227
|
/** hosts.json 캐시 초기화 (테스트용) */
|
|
209
228
|
export function resetHostsCache() {
|
|
210
229
|
hostsCache = null;
|
|
230
|
+
hostsCacheRoot = null;
|
|
211
231
|
}
|
package/hub/lib/ssh-retry.mjs
CHANGED
|
@@ -21,7 +21,11 @@ const TRANSIENT_RE =
|
|
|
21
21
|
* @returns {string|Buffer} ssh stdout
|
|
22
22
|
*/
|
|
23
23
|
export function execSshWithRetry(args, opts = {}) {
|
|
24
|
-
const {
|
|
24
|
+
const {
|
|
25
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
26
|
+
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
27
|
+
...execOpts
|
|
28
|
+
} = opts;
|
|
25
29
|
|
|
26
30
|
let lastError;
|
|
27
31
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function detectChangedFiles({
|
|
4
|
+
cwd = process.cwd(),
|
|
5
|
+
includeStaged = true,
|
|
6
|
+
includeUnstaged = true,
|
|
7
|
+
includeUntracked = true,
|
|
8
|
+
} = {}) {
|
|
9
|
+
const staged = includeStaged
|
|
10
|
+
? runGit(cwd, ["diff", "--cached", "--name-only"])
|
|
11
|
+
: [];
|
|
12
|
+
const unstaged = includeUnstaged ? runGit(cwd, ["diff", "--name-only"]) : [];
|
|
13
|
+
const untracked = includeUntracked
|
|
14
|
+
? runGit(cwd, ["ls-files", "--others", "--exclude-standard"])
|
|
15
|
+
: [];
|
|
16
|
+
const files = [...new Set([...staged, ...unstaged, ...untracked])];
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
stagedCount: staged.length,
|
|
20
|
+
unstagedCount: unstaged.length,
|
|
21
|
+
untrackedCount: untracked.length,
|
|
22
|
+
totalChanged: files.length,
|
|
23
|
+
files,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hasCodeChange(opts) {
|
|
28
|
+
return detectChangedFiles(opts).totalChanged > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runGit(cwd, args) {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync("git", args, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
})
|
|
38
|
+
.split(/\r?\n/)
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
readdir,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const DEFAULT_MAX_SNAPSHOTS = 10;
|
|
17
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export const CODEX_STATE_INCLUDES = Object.freeze([
|
|
20
|
+
"config.toml",
|
|
21
|
+
"AGENTS.md",
|
|
22
|
+
"skills",
|
|
23
|
+
"agents",
|
|
24
|
+
"prompts",
|
|
25
|
+
"plugins",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const CODEX_STATE_EXCLUDES = Object.freeze([
|
|
29
|
+
"*.sqlite*",
|
|
30
|
+
".sandbox*",
|
|
31
|
+
".tmp",
|
|
32
|
+
"_archived_skills",
|
|
33
|
+
"memories",
|
|
34
|
+
"cache",
|
|
35
|
+
"log",
|
|
36
|
+
"logs",
|
|
37
|
+
"sessions",
|
|
38
|
+
"auth.json",
|
|
39
|
+
".credentials.json",
|
|
40
|
+
"*.bak*",
|
|
41
|
+
"*.tmp-*",
|
|
42
|
+
"cap_sid",
|
|
43
|
+
"installation_id",
|
|
44
|
+
"history.jsonl",
|
|
45
|
+
"models_cache.json",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export const GEMINI_STATE_INCLUDES = Object.freeze([
|
|
49
|
+
"settings.json",
|
|
50
|
+
"settings.local.json",
|
|
51
|
+
"GEMINI.md",
|
|
52
|
+
"commands",
|
|
53
|
+
"extensions",
|
|
54
|
+
"plugins",
|
|
55
|
+
"skills",
|
|
56
|
+
"agents",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export const GEMINI_STATE_EXCLUDES = Object.freeze([
|
|
60
|
+
"*.sqlite*",
|
|
61
|
+
"cache",
|
|
62
|
+
"log",
|
|
63
|
+
"logs",
|
|
64
|
+
"sessions",
|
|
65
|
+
"auth.json",
|
|
66
|
+
".credentials.json",
|
|
67
|
+
"*.bak*",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export const STATE_SNAPSHOT_THRESHOLD_MS = DAY_MS;
|
|
71
|
+
export const STATE_SNAPSHOT_MAX_SNAPSHOTS = DEFAULT_MAX_SNAPSHOTS;
|
|
72
|
+
|
|
73
|
+
function normalizePath(path) {
|
|
74
|
+
return String(path || "")
|
|
75
|
+
.replace(/\\/gu, "/")
|
|
76
|
+
.replace(/^\/+/u, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function globToRegExp(pattern) {
|
|
80
|
+
const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/gu, "\\$&");
|
|
81
|
+
return new RegExp(`^${escaped.replace(/\*/gu, ".*")}$`, "u");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compileExclude(pattern) {
|
|
85
|
+
const text = normalizePath(pattern).replace(/\/+$/u, "");
|
|
86
|
+
if (text.includes("*")) {
|
|
87
|
+
const regex = globToRegExp(text);
|
|
88
|
+
return (relativePath) => {
|
|
89
|
+
const normalized = normalizePath(relativePath);
|
|
90
|
+
return normalized.split("/").some((part) => regex.test(part));
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (relativePath) => {
|
|
95
|
+
const normalized = normalizePath(relativePath);
|
|
96
|
+
return normalized.split("/").includes(text);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSubpath(parent, child) {
|
|
101
|
+
const rel = relative(parent, child);
|
|
102
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pathStats(path) {
|
|
106
|
+
try {
|
|
107
|
+
return await stat(path);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code === "ENOENT") return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function collectFiles({ sourceDir, includes, excludeMatchers }) {
|
|
115
|
+
const sourceRoot = resolve(sourceDir);
|
|
116
|
+
const files = [];
|
|
117
|
+
|
|
118
|
+
async function visit(absPath, relativePath) {
|
|
119
|
+
if (excludeMatchers.some((matcher) => matcher(relativePath))) return;
|
|
120
|
+
|
|
121
|
+
const info = await pathStats(absPath);
|
|
122
|
+
if (!info) return;
|
|
123
|
+
if (info.isDirectory()) {
|
|
124
|
+
const children = await readdir(absPath, { withFileTypes: true });
|
|
125
|
+
for (const child of children) {
|
|
126
|
+
await visit(join(absPath, child.name), join(relativePath, child.name));
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (info.isFile()) {
|
|
131
|
+
files.push({
|
|
132
|
+
absPath,
|
|
133
|
+
relativePath: normalizePath(relativePath),
|
|
134
|
+
size: info.size,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const include of includes || []) {
|
|
140
|
+
const relativeInclude = normalizePath(include);
|
|
141
|
+
if (!relativeInclude || relativeInclude.startsWith("../")) continue;
|
|
142
|
+
const absPath = resolve(sourceRoot, relativeInclude);
|
|
143
|
+
if (!isSubpath(sourceRoot, absPath)) continue;
|
|
144
|
+
await visit(absPath, relativeInclude);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
148
|
+
return files;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function listSnapshots(destDir) {
|
|
152
|
+
const names = await readdir(destDir).catch((error) => {
|
|
153
|
+
if (error?.code === "ENOENT") return [];
|
|
154
|
+
throw error;
|
|
155
|
+
});
|
|
156
|
+
const snapshots = [];
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
if (!name.endsWith(".tar.gz")) continue;
|
|
159
|
+
const path = join(destDir, name);
|
|
160
|
+
const info = await pathStats(path);
|
|
161
|
+
if (info?.isFile()) snapshots.push({ name, path, mtimeMs: info.mtimeMs });
|
|
162
|
+
}
|
|
163
|
+
snapshots.sort(
|
|
164
|
+
(a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name),
|
|
165
|
+
);
|
|
166
|
+
return snapshots;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function copyToStaging(files, stagingDir) {
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
const targetPath = join(stagingDir, ...file.relativePath.split("/"));
|
|
172
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
173
|
+
await copyFile(file.absPath, targetPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function createArchive({ stagingDir, archivePath, files }) {
|
|
178
|
+
const listPath = join(stagingDir, ".snapshot-files");
|
|
179
|
+
const archiveName = normalizePath(relative(stagingDir, archivePath));
|
|
180
|
+
await writeFile(
|
|
181
|
+
listPath,
|
|
182
|
+
`${files.map((file) => file.relativePath).join("\n")}\n`,
|
|
183
|
+
"utf8",
|
|
184
|
+
);
|
|
185
|
+
await execFileAsync(
|
|
186
|
+
"tar",
|
|
187
|
+
["-czf", archiveName, "-C", ".", "-T", ".snapshot-files"],
|
|
188
|
+
{
|
|
189
|
+
cwd: stagingDir,
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
await rm(listPath, { force: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function pruneSnapshots(destDir, maxSnapshots) {
|
|
197
|
+
const snapshots = await listSnapshots(destDir);
|
|
198
|
+
const keep = Math.max(1, Number(maxSnapshots) || DEFAULT_MAX_SNAPSHOTS);
|
|
199
|
+
for (const snapshot of snapshots.slice(keep)) {
|
|
200
|
+
await rm(snapshot.path, { force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatStamp(date) {
|
|
205
|
+
return date
|
|
206
|
+
.toISOString()
|
|
207
|
+
.replace(/[-:]/gu, "")
|
|
208
|
+
.replace(/\.\d{3}Z$/u, "Z");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function uniqueSuffix() {
|
|
212
|
+
return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Snapshot selected user state into a rolling tar.gz archive.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} options
|
|
219
|
+
* @param {string} options.sourceDir
|
|
220
|
+
* @param {string} options.destDir
|
|
221
|
+
* @param {string[]} options.includes
|
|
222
|
+
* @param {string[]} options.excludes
|
|
223
|
+
* @param {number} options.thresholdMs
|
|
224
|
+
* @param {number} [options.maxSnapshots=10]
|
|
225
|
+
* @returns {Promise<{skipped: boolean, reason?: string, path?: string, sizeBytes?: number, fileCount?: number}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function snapshotState({
|
|
228
|
+
sourceDir,
|
|
229
|
+
destDir,
|
|
230
|
+
includes,
|
|
231
|
+
excludes = [],
|
|
232
|
+
thresholdMs = 0,
|
|
233
|
+
maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
|
|
234
|
+
}) {
|
|
235
|
+
const sourceRoot = resolve(sourceDir || "");
|
|
236
|
+
const destRoot = resolve(destDir || "");
|
|
237
|
+
const sourceInfo = await pathStats(sourceRoot);
|
|
238
|
+
if (!sourceInfo?.isDirectory()) {
|
|
239
|
+
return { skipped: true, reason: "source-missing" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await mkdir(destRoot, { recursive: true });
|
|
243
|
+
const snapshots = await listSnapshots(destRoot);
|
|
244
|
+
const newest = snapshots[0];
|
|
245
|
+
if (
|
|
246
|
+
newest &&
|
|
247
|
+
Number(thresholdMs) > 0 &&
|
|
248
|
+
Date.now() - newest.mtimeMs < Number(thresholdMs)
|
|
249
|
+
) {
|
|
250
|
+
return { skipped: true, reason: "threshold", path: newest.path };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const excludeMatchers = excludes.map((pattern) => compileExclude(pattern));
|
|
254
|
+
const files = await collectFiles({
|
|
255
|
+
sourceDir: sourceRoot,
|
|
256
|
+
includes,
|
|
257
|
+
excludeMatchers,
|
|
258
|
+
});
|
|
259
|
+
if (files.length === 0) {
|
|
260
|
+
return { skipped: true, reason: "empty" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const suffix = uniqueSuffix();
|
|
264
|
+
const stagingDir = join(tmpdir(), `tfx-state-snapshot-${suffix}`);
|
|
265
|
+
const tempArchivePath = join(stagingDir, `.state-${suffix}.tar.gz.tmp`);
|
|
266
|
+
const finalArchivePath = join(
|
|
267
|
+
destRoot,
|
|
268
|
+
`state-${formatStamp(new Date())}-${suffix}.tar.gz`,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await mkdir(stagingDir, { recursive: true });
|
|
273
|
+
await copyToStaging(files, stagingDir);
|
|
274
|
+
await createArchive({
|
|
275
|
+
stagingDir,
|
|
276
|
+
archivePath: tempArchivePath,
|
|
277
|
+
files,
|
|
278
|
+
});
|
|
279
|
+
await rename(tempArchivePath, finalArchivePath);
|
|
280
|
+
await pruneSnapshots(destRoot, maxSnapshots);
|
|
281
|
+
const archiveInfo = await stat(finalArchivePath);
|
|
282
|
+
const sizeBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
283
|
+
return {
|
|
284
|
+
skipped: false,
|
|
285
|
+
path: finalArchivePath,
|
|
286
|
+
sizeBytes: archiveInfo.size || sizeBytes,
|
|
287
|
+
fileCount: files.length,
|
|
288
|
+
};
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
291
|
+
await rm(tempArchivePath, { force: true });
|
|
292
|
+
}
|
|
293
|
+
}
|