log-llm-config-staging 1.3.80 → 1.3.82
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/dist/log_config_files/collection/directory_collector.js +45 -25
- package/dist/log_config_files/runtime/main_runner.js +23 -7
- package/dist/log_config_files/runtime/remediation_sync.js +20 -1
- package/dist/log_config_files/runtime/worktree_absent.js +50 -0
- package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
- package/dist/log_config_files/sender/batch_sender.js +49 -4
- package/package.json +1 -1
|
@@ -50,38 +50,58 @@ function collectDirectoryEntries(t) {
|
|
|
50
50
|
}
|
|
51
51
|
return results;
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
const METADATA_DIR_SCAN_MAX_DEPTH = 8;
|
|
54
|
+
function matchesDirGlob(name, glob) {
|
|
55
|
+
if (glob === '*')
|
|
56
|
+
return true;
|
|
57
|
+
// **/*.jsonl → match any extension suffix after **/
|
|
58
|
+
if (glob.startsWith('**/')) {
|
|
59
|
+
const suffix = glob.slice(3);
|
|
60
|
+
if (suffix.startsWith('*.'))
|
|
61
|
+
return name.endsWith(suffix.slice(1));
|
|
62
|
+
return name === suffix;
|
|
63
|
+
}
|
|
64
|
+
if (glob.startsWith('*.'))
|
|
65
|
+
return name.endsWith(glob.slice(1));
|
|
66
|
+
return name === glob;
|
|
67
|
+
}
|
|
68
|
+
/** Walk dir tree (bounded depth) and return the file with the highest mtime matching dir_glob. */
|
|
69
|
+
function findNewestMatchingFile(dirPath, glob, depth, maxDepth) {
|
|
70
|
+
let best = null;
|
|
58
71
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const glob = t.dir_glob ?? '*';
|
|
63
|
-
const matchName = (name) => {
|
|
64
|
-
if (glob === '*')
|
|
65
|
-
return true;
|
|
66
|
-
if (glob.startsWith('*.'))
|
|
67
|
-
return name.endsWith(glob.slice(1));
|
|
68
|
-
return name === glob;
|
|
69
|
-
};
|
|
70
|
-
try {
|
|
71
|
-
for (const entry of readdirSync(t.path, { withFileTypes: true })) {
|
|
72
|
-
if (!entry.isFile() || !matchName(entry.name))
|
|
73
|
-
continue;
|
|
72
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
73
|
+
const fullPath = join(dirPath, entry.name);
|
|
74
|
+
if (entry.isFile() && matchesDirGlob(entry.name, glob)) {
|
|
74
75
|
try {
|
|
75
|
-
const fileStat = statSync(
|
|
76
|
-
if (fileStat.mtime >
|
|
77
|
-
|
|
78
|
-
bestPath = join(t.path, entry.name);
|
|
76
|
+
const fileStat = statSync(fullPath);
|
|
77
|
+
if (!best || fileStat.mtime > best.mtime) {
|
|
78
|
+
best = { path: fullPath, mtime: fileStat.mtime };
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
catch { /* ignore unreadable files */ }
|
|
82
82
|
}
|
|
83
|
+
else if (entry.isDirectory() && depth < maxDepth) {
|
|
84
|
+
const nested = findNewestMatchingFile(fullPath, glob, depth + 1, maxDepth);
|
|
85
|
+
if (nested && (!best || nested.mtime > best.mtime)) {
|
|
86
|
+
best = nested;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
83
89
|
}
|
|
84
|
-
|
|
90
|
+
}
|
|
91
|
+
catch { /* ignore unreadable dir */ }
|
|
92
|
+
return best;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* For metadata-style DIR targets: recursively scan files matching dir_glob, return one entry
|
|
96
|
+
* with the highest mtime found. Falls back to the directory's own mtime if no files match.
|
|
97
|
+
*/
|
|
98
|
+
function collectDirectoryMetadata(t) {
|
|
99
|
+
try {
|
|
100
|
+
const dirStat = statSync(t.path);
|
|
101
|
+
const glob = t.dir_glob ?? '*';
|
|
102
|
+
const newest = findNewestMatchingFile(t.path, glob, 0, METADATA_DIR_SCAN_MAX_DEPTH);
|
|
103
|
+
const bestPath = newest?.path ?? t.path;
|
|
104
|
+
const bestMtime = newest?.mtime ?? dirStat.mtime;
|
|
85
105
|
return {
|
|
86
106
|
file_type: t.file_type,
|
|
87
107
|
file_path: bestPath,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
+
const HOME_DIR = homedir();
|
|
4
5
|
import { getFileCollectionPatterns, FILE_PATH_REGISTRY_FILE_PATTERNS_PATH } from '../../endpoint_client/index.js';
|
|
5
6
|
import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
6
7
|
import { runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
|
|
@@ -17,6 +18,7 @@ import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
|
17
18
|
import { sendConfigFile, sendConfigFilesBatch, sendHookRequestCreate, sendHookRequestUpdateManifest, sendIngestSessionStart, sendIngestSessionFinish, BATCH_CHUNK_SIZE } from '../sender/batch_sender.js';
|
|
18
19
|
import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js';
|
|
19
20
|
import { ensureWorkspaceRepoEnv, resolveProjectRoot } from './workspace_repo.js';
|
|
21
|
+
import { scanActiveWorktrees, activeWorktreeRootSet, filterConfigFilesToActiveWorktrees, } from './worktree_scanner.js';
|
|
20
22
|
import { createSignature, canonicalizePayload } from '../sender/signing.js';
|
|
21
23
|
const PROJECT_ROOT = resolveProjectRoot();
|
|
22
24
|
async function collectAllConfigFiles(endpointBase) {
|
|
@@ -67,7 +69,12 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
67
69
|
if (!patternsResponse.client_path_constants)
|
|
68
70
|
throw new Error('client_path_constants required from API response');
|
|
69
71
|
configFiles.push(...collectConfigFilesFromInstalledPlugins(patternsResponse.client_path_constants));
|
|
70
|
-
|
|
72
|
+
const worktreeReport = scanActiveWorktrees(PROJECT_ROOT, HOME_DIR);
|
|
73
|
+
const activeRoots = activeWorktreeRootSet(worktreeReport);
|
|
74
|
+
const beforeFilter = configFiles.length;
|
|
75
|
+
const filtered = filterConfigFilesToActiveWorktrees(configFiles, activeRoots);
|
|
76
|
+
hookRunLog(`worktree_scan: active=${worktreeReport.length} config_files ${beforeFilter}→${filtered.length}`);
|
|
77
|
+
return { configFiles: filtered, worktreeReport };
|
|
71
78
|
}
|
|
72
79
|
async function addSensitivePathsAudit(endpointBase, configFiles) {
|
|
73
80
|
hookRunLog(`running sensitive paths audit`);
|
|
@@ -82,7 +89,7 @@ async function addSensitivePathsAudit(endpointBase, configFiles) {
|
|
|
82
89
|
configFiles.push({ file_type: 'sensitive_paths_audit', file_path: 'sensitive_paths_audit.txt', raw_content: { paths: [] } });
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
|
-
async function sendAllConfigFiles(configFiles, hardwareUuid, authKey) {
|
|
92
|
+
async function sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey) {
|
|
86
93
|
const hookTypeRaw = (process.env.OPTIMUS_HOOK_TYPE || 'claude').toLowerCase();
|
|
87
94
|
const hookType = hookTypeRaw === 'cursor' ? 'cursor' : 'claude';
|
|
88
95
|
const manifest = configFiles.map((c) => canonicalCursorUserStateVscdbPath(c.file_path));
|
|
@@ -101,9 +108,15 @@ async function sendAllConfigFiles(configFiles, hardwareUuid, authKey) {
|
|
|
101
108
|
const ok = await sendHookRequestUpdateManifest(hardwareUuid, authKey, hookRequestId, manifest);
|
|
102
109
|
hookRunLog(`hook-request update manifest result=${ok ? 'ok' : 'fail'}`);
|
|
103
110
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
// Finish ingest session: OpenClaw hold set, worktree prune (report may be []), and scans.
|
|
112
|
+
// Run finish whenever a session was started — even if every upload failed — so worktree
|
|
113
|
+
// cleanup still runs for machines with only stale worktrees on disk.
|
|
114
|
+
if (ingestSessionId) {
|
|
115
|
+
const ok = await sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, batchResult.failedArtifacts, worktreeReport);
|
|
116
|
+
hookRunLog(`ingest-session finish result=${ok ? 'ok' : 'fail'}`
|
|
117
|
+
+ (batchResult.failedArtifacts.length > 0
|
|
118
|
+
? ` held=${batchResult.failedArtifacts.length} failed upload(s)`
|
|
119
|
+
: ''));
|
|
107
120
|
}
|
|
108
121
|
// Exit 0 if anything was persisted so the shell hook keeps last_log and throttles. Exit 1 only when
|
|
109
122
|
// every item failed (e.g. SQLite locked on server) — otherwise partial success used to return 1,
|
|
@@ -129,8 +142,11 @@ async function main() {
|
|
|
129
142
|
throw err;
|
|
130
143
|
}
|
|
131
144
|
let configFiles;
|
|
145
|
+
let worktreeReport = [];
|
|
132
146
|
try {
|
|
133
|
-
|
|
147
|
+
const collected = await collectAllConfigFiles(endpointBase);
|
|
148
|
+
configFiles = collected.configFiles;
|
|
149
|
+
worktreeReport = collected.worktreeReport;
|
|
134
150
|
}
|
|
135
151
|
catch (err) {
|
|
136
152
|
hookRunLog(`patterns_error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -154,7 +170,7 @@ async function main() {
|
|
|
154
170
|
hookRunLog('no config files found, exiting');
|
|
155
171
|
process.exit(0);
|
|
156
172
|
}
|
|
157
|
-
await sendAllConfigFiles(configFiles, hardwareUuid, authKey);
|
|
173
|
+
await sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey);
|
|
158
174
|
}
|
|
159
175
|
async function logSingleFile(filePath) {
|
|
160
176
|
const hardwareUuid = resolveHardwareUuid();
|
|
@@ -11,6 +11,8 @@ import { loadEndpointBase } from '../sender/endpoint_config.js';
|
|
|
11
11
|
import { tryResolveHardwareUuid } from './hardware_uuid.js';
|
|
12
12
|
import { CURSOR_SCALAR_ITEMTABLE_FIELDS, persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
|
|
13
13
|
import { sendConfigFile } from '../sender/batch_sender.js';
|
|
14
|
+
import { parseWorktreeRootFromPath } from './worktree_scanner.js';
|
|
15
|
+
import { reportAbsentWorktrees } from './worktree_absent.js';
|
|
14
16
|
import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
|
|
15
17
|
import { resolveRemediationConfigPath } from './remediation_config_path.js';
|
|
16
18
|
import { resolveSqlite3Binary } from './sqlite_binary.js';
|
|
@@ -1189,9 +1191,26 @@ export function enforceRemediation(instruction) {
|
|
|
1189
1191
|
if (fixSpec?.file_format !== 'json') {
|
|
1190
1192
|
return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
|
|
1191
1193
|
}
|
|
1194
|
+
const worktreeRoot = parseWorktreeRootFromPath(inst.config_file_path);
|
|
1195
|
+
if (worktreeRoot && !existsSync(worktreeRoot)) {
|
|
1196
|
+
void reportAbsentWorktrees([
|
|
1197
|
+
{
|
|
1198
|
+
worktree_root: worktreeRoot,
|
|
1199
|
+
reason: 'path_missing_on_remediate',
|
|
1200
|
+
source_path: inst.config_file_path,
|
|
1201
|
+
},
|
|
1202
|
+
]);
|
|
1203
|
+
return fail('worktree_absent');
|
|
1204
|
+
}
|
|
1192
1205
|
const dir = dirname(inst.config_file_path);
|
|
1193
|
-
if (
|
|
1206
|
+
if (worktreeRoot) {
|
|
1207
|
+
if (!existsSync(dir)) {
|
|
1208
|
+
return fail('worktree_path_missing');
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
else if (!existsSync(dir)) {
|
|
1194
1212
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1213
|
+
}
|
|
1195
1214
|
let configJson = {};
|
|
1196
1215
|
if (existsSync(inst.config_file_path)) {
|
|
1197
1216
|
try {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { executeBody } from '../../endpoint_client/http_transport.js';
|
|
2
|
+
import { buildApiUrl } from '../../endpoint_client/registry_api.js';
|
|
3
|
+
import { loadEndpointBase } from '../sender/endpoint_config.js';
|
|
4
|
+
import { createSignature } from '../sender/signing.js';
|
|
5
|
+
import { readStoredAuthKey } from '../auth/auth_key_store.js';
|
|
6
|
+
import { tryResolveHardwareUuid } from './hardware_uuid.js';
|
|
7
|
+
import { hookRunLog } from './hook_logger.js';
|
|
8
|
+
import { normalizePathForWorktree } from './worktree_scanner.js';
|
|
9
|
+
/**
|
|
10
|
+
* Notify server that worktree directories are gone so inventory/findings are pruned.
|
|
11
|
+
*/
|
|
12
|
+
export function reportAbsentWorktrees(entries) {
|
|
13
|
+
const authKey = readStoredAuthKey();
|
|
14
|
+
if (!authKey) {
|
|
15
|
+
hookRunLog('worktree_absent: no auth key, skipping');
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
const hardwareUuid = tryResolveHardwareUuid();
|
|
19
|
+
if (!hardwareUuid) {
|
|
20
|
+
hookRunLog('worktree_absent: no hardware uuid, skipping');
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
const absent_worktrees = entries
|
|
24
|
+
.map((e) => ({
|
|
25
|
+
worktree_root: normalizePathForWorktree(e.worktree_root).replace(/\/+$/, ''),
|
|
26
|
+
reason: (e.reason || 'path_missing').slice(0, 64),
|
|
27
|
+
}))
|
|
28
|
+
.filter((e) => e.worktree_root)
|
|
29
|
+
.sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
|
|
30
|
+
if (absent_worktrees.length === 0) {
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
const endpointBase = loadEndpointBase();
|
|
34
|
+
const url = buildApiUrl(endpointBase, '/endpoint_security/worktree-absent/');
|
|
35
|
+
const payload = { hardware_uuid: hardwareUuid, absent_worktrees };
|
|
36
|
+
const signature = createSignature(payload, authKey.key);
|
|
37
|
+
const body = JSON.stringify({ ...payload, signature, key_id: authKey.key_id || '' });
|
|
38
|
+
return executeBody(url, 'POST', body, 8000)
|
|
39
|
+
.then(({ statusCode }) => {
|
|
40
|
+
if (statusCode !== 200) {
|
|
41
|
+
hookRunLog(`worktree_absent: server returned ${statusCode}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
hookRunLog(`worktree_absent: reported ${absent_worktrees.length} root(s)`);
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
.catch((err) => {
|
|
48
|
+
hookRunLog(`worktree_absent: request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Worktrees touched within this window are reported and collected (Phase 1). */
|
|
4
|
+
export const WORKTREE_ACTIVE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const IN_REPO_SEGMENT = '/.claude/worktrees/';
|
|
6
|
+
const HOME_SEGMENT = '/.claude-worktrees/';
|
|
7
|
+
export function normalizePathForWorktree(path) {
|
|
8
|
+
return path.replace(/\\/g, '/');
|
|
9
|
+
}
|
|
10
|
+
export function parseWorktreeRootFromPath(filePath) {
|
|
11
|
+
const norm = normalizePathForWorktree(filePath);
|
|
12
|
+
if (norm.includes(HOME_SEGMENT)) {
|
|
13
|
+
const parts = norm.split(HOME_SEGMENT);
|
|
14
|
+
if (parts.length !== 2)
|
|
15
|
+
return null;
|
|
16
|
+
const prefix = parts[0];
|
|
17
|
+
const after = parts[1].replace(/^\/+/, '');
|
|
18
|
+
const segments = after.split('/');
|
|
19
|
+
if (segments.length < 2)
|
|
20
|
+
return null;
|
|
21
|
+
return `${prefix}${HOME_SEGMENT}${segments[0]}/${segments[1]}`.replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
if (norm.includes(IN_REPO_SEGMENT)) {
|
|
24
|
+
const idx = norm.indexOf(IN_REPO_SEGMENT);
|
|
25
|
+
const before = norm.slice(0, idx).replace(/\/+$/, '');
|
|
26
|
+
const after = norm.slice(idx + IN_REPO_SEGMENT.length).replace(/^\/+/, '');
|
|
27
|
+
const name = after.split('/')[0];
|
|
28
|
+
if (!name)
|
|
29
|
+
return null;
|
|
30
|
+
return `${before}${IN_REPO_SEGMENT}${name}`.replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function isActiveDir(dirPath, maxAgeMs) {
|
|
35
|
+
if (!existsSync(dirPath))
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
const st = statSync(dirPath);
|
|
39
|
+
if (!st.isDirectory())
|
|
40
|
+
return false;
|
|
41
|
+
return Date.now() - st.mtimeMs <= maxAgeMs;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function scanInRepoWorktrees(projectRoot, maxAgeMs) {
|
|
48
|
+
const base = join(projectRoot, '.claude', 'worktrees');
|
|
49
|
+
if (!existsSync(base))
|
|
50
|
+
return [];
|
|
51
|
+
const repoName = projectRoot.split(/[/\\]/).filter(Boolean).pop() || '';
|
|
52
|
+
const entries = [];
|
|
53
|
+
try {
|
|
54
|
+
for (const dirent of readdirSync(base, { withFileTypes: true })) {
|
|
55
|
+
if (!dirent.isDirectory())
|
|
56
|
+
continue;
|
|
57
|
+
const root = join(base, dirent.name);
|
|
58
|
+
if (!isActiveDir(root, maxAgeMs))
|
|
59
|
+
continue;
|
|
60
|
+
const st = statSync(root);
|
|
61
|
+
entries.push({
|
|
62
|
+
worktree_root: normalizePathForWorktree(root),
|
|
63
|
+
worktree_name: dirent.name,
|
|
64
|
+
repo_identifier: repoName,
|
|
65
|
+
layout: 'in_repo',
|
|
66
|
+
last_modified: st.mtime.toISOString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
function scanHomeWorktrees(home, maxAgeMs) {
|
|
76
|
+
const base = join(home, '.claude-worktrees');
|
|
77
|
+
if (!existsSync(base))
|
|
78
|
+
return [];
|
|
79
|
+
const entries = [];
|
|
80
|
+
try {
|
|
81
|
+
for (const repoDir of readdirSync(base, { withFileTypes: true })) {
|
|
82
|
+
if (!repoDir.isDirectory())
|
|
83
|
+
continue;
|
|
84
|
+
const repoPath = join(base, repoDir.name);
|
|
85
|
+
let worktreeNames = [];
|
|
86
|
+
try {
|
|
87
|
+
worktreeNames = readdirSync(repoPath, { withFileTypes: true })
|
|
88
|
+
.filter((d) => d.isDirectory())
|
|
89
|
+
.map((d) => d.name);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
for (const wtName of worktreeNames) {
|
|
95
|
+
const root = join(repoPath, wtName);
|
|
96
|
+
if (!isActiveDir(root, maxAgeMs))
|
|
97
|
+
continue;
|
|
98
|
+
const st = statSync(root);
|
|
99
|
+
entries.push({
|
|
100
|
+
worktree_root: normalizePathForWorktree(root),
|
|
101
|
+
worktree_name: wtName,
|
|
102
|
+
repo_identifier: repoDir.name,
|
|
103
|
+
layout: 'home',
|
|
104
|
+
last_modified: st.mtime.toISOString(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
return entries;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Scan project and home Claude worktree directories; return those active within maxAgeMs.
|
|
116
|
+
*/
|
|
117
|
+
export function scanActiveWorktrees(projectRoot, home, maxAgeMs = WORKTREE_ACTIVE_MAX_AGE_MS) {
|
|
118
|
+
const byRoot = new Map();
|
|
119
|
+
for (const e of [...scanInRepoWorktrees(projectRoot, maxAgeMs), ...scanHomeWorktrees(home, maxAgeMs)]) {
|
|
120
|
+
byRoot.set(e.worktree_root, e);
|
|
121
|
+
}
|
|
122
|
+
return [...byRoot.values()].sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
|
|
123
|
+
}
|
|
124
|
+
export function activeWorktreeRootSet(entries) {
|
|
125
|
+
return new Set(entries.map((e) => e.worktree_root));
|
|
126
|
+
}
|
|
127
|
+
export function filterConfigFilesToActiveWorktrees(files, activeRoots) {
|
|
128
|
+
if (activeRoots.size === 0) {
|
|
129
|
+
return files.filter((f) => parseWorktreeRootFromPath(f.file_path) === null);
|
|
130
|
+
}
|
|
131
|
+
return files.filter((f) => {
|
|
132
|
+
const root = parseWorktreeRootFromPath(f.file_path);
|
|
133
|
+
if (root === null)
|
|
134
|
+
return true;
|
|
135
|
+
return activeRoots.has(root);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -11,6 +11,17 @@ import path from 'node:path';
|
|
|
11
11
|
export const BATCH_CHUNK_SIZE = 20;
|
|
12
12
|
const MAX_BATCH_SIZE_BYTES = 500 * 1024; // 500KB
|
|
13
13
|
export { resolveRepoFromPath } from '../runtime/workspace_repo.js';
|
|
14
|
+
function recordFailedArtifacts(target, files, seen) {
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const file_path = canonicalCursorUserStateVscdbPath(file.file_path);
|
|
17
|
+
const file_type = file.file_type || '';
|
|
18
|
+
const key = `${file_type}\t${file_path}`;
|
|
19
|
+
if (!file_type || seen.has(key))
|
|
20
|
+
continue;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
target.push({ file_path, file_type });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
function resolveApiBase(endpoint) {
|
|
15
26
|
try {
|
|
16
27
|
return new URL(endpoint).origin;
|
|
@@ -92,6 +103,8 @@ async function sendConfigFilesBatch(configFiles, hardwareUuid, authKey, hookRequ
|
|
|
92
103
|
const basePayloadSize = JSON.stringify({ hardware_uuid: hardwareUuid, metadata }).length + 800;
|
|
93
104
|
const chunks = buildBatchChunks(configFiles, basePayloadSize);
|
|
94
105
|
const totals = { accepted: 0, failed: 0 };
|
|
106
|
+
const failedArtifacts = [];
|
|
107
|
+
const failedArtifactKeys = new Set();
|
|
95
108
|
let chunkIndex = 0;
|
|
96
109
|
while (chunkIndex < chunks.length) {
|
|
97
110
|
let chunk = chunks[chunkIndex];
|
|
@@ -118,11 +131,21 @@ async function sendConfigFilesBatch(configFiles, hardwareUuid, authKey, hookRequ
|
|
|
118
131
|
totals.accepted += typeof response.accepted === 'number' ? response.accepted : chunk.length;
|
|
119
132
|
const failedList = Array.isArray(response.failed) ? response.failed : [];
|
|
120
133
|
totals.failed += failedList.length;
|
|
121
|
-
if (failedList.length > 0)
|
|
134
|
+
if (failedList.length > 0) {
|
|
122
135
|
hookRunLog(`batch chunk failed items: ${JSON.stringify(failedList.slice(0, 3))}`);
|
|
136
|
+
for (const item of failedList) {
|
|
137
|
+
const idx = typeof item.index === 'number'
|
|
138
|
+
? item.index
|
|
139
|
+
: -1;
|
|
140
|
+
if (idx >= 0 && idx < chunk.length) {
|
|
141
|
+
recordFailedArtifacts(failedArtifacts, [chunk[idx]], failedArtifactKeys);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
123
145
|
}
|
|
124
146
|
else {
|
|
125
147
|
totals.failed += chunk.length;
|
|
148
|
+
recordFailedArtifacts(failedArtifacts, chunk, failedArtifactKeys);
|
|
126
149
|
hookRunLog(`batch chunk failed: ${response.error || response.message || response.status}`);
|
|
127
150
|
}
|
|
128
151
|
}
|
|
@@ -130,10 +153,11 @@ async function sendConfigFilesBatch(configFiles, hardwareUuid, authKey, hookRequ
|
|
|
130
153
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
131
154
|
hookRunLog(`batch chunk error: ${errorMessage}`);
|
|
132
155
|
totals.failed += chunk.length;
|
|
156
|
+
recordFailedArtifacts(failedArtifacts, chunk, failedArtifactKeys);
|
|
133
157
|
}
|
|
134
158
|
chunkIndex++;
|
|
135
159
|
}
|
|
136
|
-
return totals;
|
|
160
|
+
return { ...totals, failedArtifacts };
|
|
137
161
|
}
|
|
138
162
|
async function sendIngestSessionStart(hardwareUuid, authKey) {
|
|
139
163
|
const endpoint = loadEndpointBase();
|
|
@@ -153,10 +177,31 @@ async function sendIngestSessionStart(hardwareUuid, authKey) {
|
|
|
153
177
|
return null;
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
|
-
async function sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId) {
|
|
180
|
+
async function sendIngestSessionFinish(hardwareUuid, authKey, ingestSessionId, failedUploads = [], worktreeReport = []) {
|
|
157
181
|
const endpoint = loadEndpointBase();
|
|
158
182
|
const apiUrl = `${resolveApiBase(endpoint)}/endpoint_security/ingest-session/finish/`;
|
|
159
|
-
const
|
|
183
|
+
const failed_uploads = [...failedUploads]
|
|
184
|
+
.map(({ file_path, file_type }) => ({ file_path, file_type }))
|
|
185
|
+
.sort((a, b) => a.file_path.localeCompare(b.file_path) || a.file_type.localeCompare(b.file_type));
|
|
186
|
+
const payload = {
|
|
187
|
+
hardware_uuid: hardwareUuid,
|
|
188
|
+
action: 'ingest_session_finish',
|
|
189
|
+
ingest_session_id: ingestSessionId,
|
|
190
|
+
};
|
|
191
|
+
if (failed_uploads.length > 0) {
|
|
192
|
+
payload.failed_uploads = failed_uploads;
|
|
193
|
+
}
|
|
194
|
+
const worktree_report = [...worktreeReport]
|
|
195
|
+
.map((e) => ({
|
|
196
|
+
worktree_root: e.worktree_root,
|
|
197
|
+
worktree_name: e.worktree_name,
|
|
198
|
+
repo_identifier: e.repo_identifier,
|
|
199
|
+
layout: e.layout,
|
|
200
|
+
last_modified: e.last_modified,
|
|
201
|
+
}))
|
|
202
|
+
.sort((a, b) => a.worktree_root.localeCompare(b.worktree_root));
|
|
203
|
+
// Always send the key (may be []) so the server prunes stale worktrees when none are active.
|
|
204
|
+
payload.worktree_report = worktree_report;
|
|
160
205
|
const signature = createSignature(payload, authKey.key);
|
|
161
206
|
const body = { ...payload, signature, key_id: authKey.key_id || '' };
|
|
162
207
|
try {
|