@link-assistant/hive-mind 2.13.4 → 2.13.5
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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/gemini.lib.mjs +5 -1
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +4 -277
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
package/src/hive.mjs
CHANGED
|
@@ -103,125 +103,10 @@ if (isRunningDirectly) {
|
|
|
103
103
|
const commandName = process.argv[1] ? process.argv[1].split('/').pop() : '';
|
|
104
104
|
const isLocalScript = commandName.endsWith('.mjs');
|
|
105
105
|
const solveCommand = isLocalScript ? './solve.mjs' : 'solve';
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
* @param {string} scope - 'organization' or 'user'
|
|
111
|
-
* @param {string} monitorTag - Label to filter by (optional)
|
|
112
|
-
* @param {boolean} allIssues - Whether to fetch all issues or only labeled ones
|
|
113
|
-
* @returns {Promise<Array>} Array of issues
|
|
114
|
-
*/
|
|
115
|
-
async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
|
|
116
|
-
try {
|
|
117
|
-
await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
|
|
118
|
-
// Strategy 1: Try GraphQL approach first (faster but has limitations)
|
|
119
|
-
// Only try GraphQL for "all issues" mode, not for labeled issues
|
|
120
|
-
if (fetchAllIssues) {
|
|
121
|
-
const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
|
|
122
|
-
if (graphqlResult.success) {
|
|
123
|
-
await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
|
|
124
|
-
return graphqlResult.issues;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
|
|
128
|
-
await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
|
|
129
|
-
|
|
130
|
-
// Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
|
|
131
|
-
let repoListCmd;
|
|
132
|
-
if (scope === 'organization') {
|
|
133
|
-
repoListCmd = `gh api orgs/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
134
|
-
} else {
|
|
135
|
-
repoListCmd = `gh api users/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
136
|
-
}
|
|
137
|
-
await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
|
|
138
|
-
await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
|
|
139
|
-
// Add delay for rate limiting
|
|
140
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
141
|
-
// #1756: route through execGhWithRetry for transient 5xx + rate-limit
|
|
142
|
-
const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
|
|
143
|
-
execOptions: { encoding: 'utf8', env: process.env },
|
|
144
|
-
label: `gh api ${scope} repos (paginated)`,
|
|
145
|
-
});
|
|
146
|
-
// Parse the output line by line, as gh api with --jq outputs one JSON object per line
|
|
147
|
-
const repoLines = repoOutput
|
|
148
|
-
.trim()
|
|
149
|
-
.split('\n')
|
|
150
|
-
.filter(line => line.trim());
|
|
151
|
-
const allRepositories = repoLines.map(line => JSON.parse(line));
|
|
152
|
-
await log(` 📊 Found ${allRepositories.length} repositories`);
|
|
153
|
-
// Filter repositories to only include those owned by the target user/org
|
|
154
|
-
const ownedRepositories = allRepositories.filter(repo => {
|
|
155
|
-
const repoOwner = repo.owner?.login || repo.owner;
|
|
156
|
-
return repoOwner === owner;
|
|
157
|
-
});
|
|
158
|
-
const unownedCount = allRepositories.length - ownedRepositories.length;
|
|
159
|
-
if (unownedCount > 0) {
|
|
160
|
-
await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
|
|
161
|
-
}
|
|
162
|
-
// Filter out archived repositories from owned repositories
|
|
163
|
-
const repositories = ownedRepositories.filter(repo => !repo.isArchived);
|
|
164
|
-
const archivedCount = ownedRepositories.length - repositories.length;
|
|
165
|
-
if (archivedCount > 0) {
|
|
166
|
-
await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
|
|
167
|
-
}
|
|
168
|
-
await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
|
|
169
|
-
let collectedIssues = [];
|
|
170
|
-
let processedRepos = 0;
|
|
171
|
-
// Process repositories in batches to avoid overwhelming the API
|
|
172
|
-
for (const repo of repositories) {
|
|
173
|
-
try {
|
|
174
|
-
const repoName = repo.name;
|
|
175
|
-
const ownerName = repo.owner?.login || owner;
|
|
176
|
-
await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
|
|
177
|
-
// Build the appropriate issue list command
|
|
178
|
-
let issueCmd;
|
|
179
|
-
if (fetchAllIssues) {
|
|
180
|
-
issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --json url,title,number,createdAt`;
|
|
181
|
-
} else {
|
|
182
|
-
issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --label "${monitorTag}" --json url,title,number,createdAt`;
|
|
183
|
-
}
|
|
184
|
-
// Add delay between repository requests
|
|
185
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
186
|
-
const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
|
|
187
|
-
// Add repository information to each issue
|
|
188
|
-
const issuesWithRepo = repoIssues.map(issue => ({
|
|
189
|
-
...issue,
|
|
190
|
-
repository: {
|
|
191
|
-
name: repoName,
|
|
192
|
-
owner: { login: ownerName },
|
|
193
|
-
},
|
|
194
|
-
}));
|
|
195
|
-
collectedIssues.push(...issuesWithRepo);
|
|
196
|
-
processedRepos++;
|
|
197
|
-
if (issuesWithRepo.length > 0) {
|
|
198
|
-
await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
|
|
199
|
-
}
|
|
200
|
-
} catch (repoError) {
|
|
201
|
-
reportError(repoError, {
|
|
202
|
-
context: 'fetchIssuesFromRepositories',
|
|
203
|
-
repo: repo.name,
|
|
204
|
-
operation: 'fetch_repo_issues',
|
|
205
|
-
});
|
|
206
|
-
await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, {
|
|
207
|
-
verbose: true,
|
|
208
|
-
});
|
|
209
|
-
// Continue with other repositories
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
|
|
213
|
-
return collectedIssues;
|
|
214
|
-
} catch (error) {
|
|
215
|
-
reportError(error, {
|
|
216
|
-
context: 'fetchIssuesFromRepositories',
|
|
217
|
-
owner,
|
|
218
|
-
scope,
|
|
219
|
-
operation: 'repository_fallback',
|
|
220
|
-
});
|
|
221
|
-
await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
}
|
|
106
|
+
// Repository-by-repository fallback lives in its own module so hive.mjs stays
|
|
107
|
+
// under the 1350-line early-warning threshold (issue #2175, warning from #1593).
|
|
108
|
+
const repositoryFallbackLib = await import('./hive.repository-fallback.lib.mjs');
|
|
109
|
+
const fetchIssuesFromRepositories = repositoryFallbackLib.createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError });
|
|
225
110
|
// Configure command line arguments - GitHub URL as positional argument
|
|
226
111
|
const rawArgs = normalizeCliArgs(hideBin(process.argv));
|
|
227
112
|
// Use .parse() instead of .argv to ensure .strict() mode works correctly
|
|
@@ -1398,37 +1283,10 @@ if (isRunningDirectly) {
|
|
|
1398
1283
|
delegateSignalHandling(true);
|
|
1399
1284
|
process.on('SIGINT', () => gracefulShutdown('interrupt'));
|
|
1400
1285
|
process.on('SIGTERM', () => gracefulShutdown('termination'));
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
});
|
|
1406
|
-
await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', {
|
|
1407
|
-
verbose: true,
|
|
1408
|
-
});
|
|
1409
|
-
} else {
|
|
1410
|
-
// Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
|
|
1411
|
-
// start, and report an exhausted disk as the environment condition it is (exit 75) instead of
|
|
1412
|
-
// a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
|
|
1413
|
-
// directly, which skips the log-flushing safeExit path and printed no actionable reason.
|
|
1414
|
-
const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
|
|
1415
|
-
const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
|
|
1416
|
-
if (!startupDiskGuard.ok) {
|
|
1417
|
-
await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
|
|
1418
|
-
await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
|
|
1419
|
-
await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
|
|
1420
|
-
}
|
|
1421
|
-
const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
|
|
1422
|
-
if (!systemCheck.success) {
|
|
1423
|
-
await safeExit(1, 'System resource check failed');
|
|
1424
|
-
}
|
|
1425
|
-
// Validate the selected AI tool connection before starting monitoring with the same model that will be used
|
|
1426
|
-
const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
|
|
1427
|
-
if (!isToolConnected) {
|
|
1428
|
-
await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
|
|
1429
|
-
await safeExit(1, 'Error occurred');
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1286
|
+
// Pre-flight checks live in hive.startup-checks.lib.mjs so hive.mjs stays under
|
|
1287
|
+
// the 1350-line early-warning threshold (issue #2175, warning from #1593).
|
|
1288
|
+
const startupChecksLib = await import('./hive.startup-checks.lib.mjs');
|
|
1289
|
+
await startupChecksLib.runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE });
|
|
1432
1290
|
// Wrap monitor function with Sentry error tracking
|
|
1433
1291
|
const monitorWithSentry = !argv.sentry ? monitor : withSentry(monitor, 'hive.monitor', 'command');
|
|
1434
1292
|
// Start monitoring
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-by-repository issue fetching fallback for the hive.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
|
|
5
|
+
* 1350-line early-warning threshold that protects concurrent merges (#1593).
|
|
6
|
+
* The logic is unchanged; it now takes its collaborators as parameters instead
|
|
7
|
+
* of closing over the dynamically imported bindings in hive.mjs, which also
|
|
8
|
+
* makes it unit-testable (see tests/hive-repository-fallback-2175.test.mjs).
|
|
9
|
+
*
|
|
10
|
+
* Used when GitHub's search API is rate-limited: rather than giving up, every
|
|
11
|
+
* repository owned by the org/user is listed and queried directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the repository-fallback fetcher with its dependencies bound.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {Function} deps.log
|
|
19
|
+
* @param {Function} deps.cleanErrorMessage
|
|
20
|
+
* @param {Function} deps.tryFetchIssuesWithGraphQL
|
|
21
|
+
* @param {Function} deps.execGhWithRetry
|
|
22
|
+
* @param {Function} deps.fetchAllIssuesWithPagination
|
|
23
|
+
* @param {Function} deps.reportError
|
|
24
|
+
* @param {(ms: number) => Promise<void>} [deps.sleeper] delay between API calls
|
|
25
|
+
* @returns {(owner: string, scope: string, monitorTag?: string, fetchAllIssues?: boolean) => Promise<Array>}
|
|
26
|
+
*/
|
|
27
|
+
export function createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError, sleeper = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
|
|
28
|
+
/**
|
|
29
|
+
* Fallback function to fetch issues from organization/user repositories
|
|
30
|
+
* when search API hits rate limits
|
|
31
|
+
* @param {string} owner - Organization or user name
|
|
32
|
+
* @param {string} scope - 'organization' or 'user'
|
|
33
|
+
* @param {string} monitorTag - Label to filter by (optional)
|
|
34
|
+
* @param {boolean} fetchAllIssues - Whether to fetch all issues or only labeled ones
|
|
35
|
+
* @returns {Promise<Array>} Array of issues
|
|
36
|
+
*/
|
|
37
|
+
return async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
|
|
38
|
+
try {
|
|
39
|
+
await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
|
|
40
|
+
// Strategy 1: Try GraphQL approach first (faster but has limitations)
|
|
41
|
+
// Only try GraphQL for "all issues" mode, not for labeled issues
|
|
42
|
+
if (fetchAllIssues) {
|
|
43
|
+
const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
|
|
44
|
+
if (graphqlResult.success) {
|
|
45
|
+
await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
|
|
46
|
+
return graphqlResult.issues;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
|
|
50
|
+
await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
|
|
51
|
+
|
|
52
|
+
// Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
|
|
53
|
+
const scopePath = scope === 'organization' ? 'orgs' : 'users';
|
|
54
|
+
const repoListCmd = `gh api ${scopePath}/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
55
|
+
await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
|
|
56
|
+
await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
|
|
57
|
+
// Add delay for rate limiting
|
|
58
|
+
await sleeper(2000);
|
|
59
|
+
// #1756: route through execGhWithRetry for transient 5xx + rate-limit
|
|
60
|
+
const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
|
|
61
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
62
|
+
label: `gh api ${scope} repos (paginated)`,
|
|
63
|
+
});
|
|
64
|
+
// Parse the output line by line, as gh api with --jq outputs one JSON object per line
|
|
65
|
+
const repoLines = repoOutput
|
|
66
|
+
.trim()
|
|
67
|
+
.split('\n')
|
|
68
|
+
.filter(line => line.trim());
|
|
69
|
+
const allRepositories = repoLines.map(line => JSON.parse(line));
|
|
70
|
+
await log(` 📊 Found ${allRepositories.length} repositories`);
|
|
71
|
+
// Filter repositories to only include those owned by the target user/org
|
|
72
|
+
const ownedRepositories = allRepositories.filter(repo => {
|
|
73
|
+
const repoOwner = repo.owner?.login || repo.owner;
|
|
74
|
+
return repoOwner === owner;
|
|
75
|
+
});
|
|
76
|
+
const unownedCount = allRepositories.length - ownedRepositories.length;
|
|
77
|
+
if (unownedCount > 0) {
|
|
78
|
+
await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
|
|
79
|
+
}
|
|
80
|
+
// Filter out archived repositories from owned repositories
|
|
81
|
+
const repositories = ownedRepositories.filter(repo => !repo.isArchived);
|
|
82
|
+
const archivedCount = ownedRepositories.length - repositories.length;
|
|
83
|
+
if (archivedCount > 0) {
|
|
84
|
+
await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
|
|
85
|
+
}
|
|
86
|
+
await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
|
|
87
|
+
const collectedIssues = [];
|
|
88
|
+
let processedRepos = 0;
|
|
89
|
+
// Process repositories in batches to avoid overwhelming the API
|
|
90
|
+
for (const repo of repositories) {
|
|
91
|
+
try {
|
|
92
|
+
const repoName = repo.name;
|
|
93
|
+
const ownerName = repo.owner?.login || owner;
|
|
94
|
+
await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
|
|
95
|
+
// Build the appropriate issue list command
|
|
96
|
+
const labelFilter = fetchAllIssues ? '' : ` --label "${monitorTag}"`;
|
|
97
|
+
const issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open${labelFilter} --json url,title,number,createdAt`;
|
|
98
|
+
// Add delay between repository requests
|
|
99
|
+
await sleeper(1000);
|
|
100
|
+
const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
|
|
101
|
+
// Add repository information to each issue
|
|
102
|
+
const issuesWithRepo = repoIssues.map(issue => ({
|
|
103
|
+
...issue,
|
|
104
|
+
repository: { name: repoName, owner: { login: ownerName } },
|
|
105
|
+
}));
|
|
106
|
+
collectedIssues.push(...issuesWithRepo);
|
|
107
|
+
processedRepos++;
|
|
108
|
+
if (issuesWithRepo.length > 0) {
|
|
109
|
+
await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
|
|
110
|
+
}
|
|
111
|
+
} catch (repoError) {
|
|
112
|
+
reportError(repoError, { context: 'fetchIssuesFromRepositories', repo: repo.name, operation: 'fetch_repo_issues' });
|
|
113
|
+
await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, { verbose: true });
|
|
114
|
+
// Continue with other repositories
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
|
|
118
|
+
return collectedIssues;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
reportError(error, { context: 'fetchIssuesFromRepositories', owner, scope, operation: 'repository_fallback' });
|
|
121
|
+
await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-flight checks the hive runs before it starts monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
|
|
5
|
+
* 1350-line early-warning threshold that protects concurrent merges (#1593).
|
|
6
|
+
* Behaviour is unchanged; the collaborators that used to be closure bindings in
|
|
7
|
+
* hive.mjs are parameters, which also makes the sequence unit-testable
|
|
8
|
+
* (see tests/hive-startup-checks-2175.test.mjs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run the startup checks, exiting through `safeExit` when one fails.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} deps
|
|
15
|
+
* @param {object} deps.argv parsed CLI arguments
|
|
16
|
+
* @param {Function} deps.log
|
|
17
|
+
* @param {Function} deps.safeExit
|
|
18
|
+
* @param {Function} deps.ensureDiskSpaceForWorker
|
|
19
|
+
* @param {Function} deps.checkSystem
|
|
20
|
+
* @param {Function} deps.validateToolConnection
|
|
21
|
+
* @param {Function} deps.validateClaudeConnection
|
|
22
|
+
* @param {number} deps.EXIT_CODE_INSUFFICIENT_DISK_SPACE
|
|
23
|
+
* @returns {Promise<{skipped: boolean}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE }) {
|
|
26
|
+
if (argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) {
|
|
27
|
+
await log('⏩ Skipping system resource check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
|
|
28
|
+
await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
|
|
29
|
+
return { skipped: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
|
|
33
|
+
// start, and report an exhausted disk as the environment condition it is (exit 75) instead of
|
|
34
|
+
// a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
|
|
35
|
+
// directly, which skips the log-flushing safeExit path and printed no actionable reason.
|
|
36
|
+
const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
|
|
37
|
+
const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
|
|
38
|
+
if (!startupDiskGuard.ok) {
|
|
39
|
+
await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
|
|
40
|
+
await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
|
|
41
|
+
await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
|
|
45
|
+
if (!systemCheck.success) {
|
|
46
|
+
await safeExit(1, 'System resource check failed');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validate the selected AI tool connection before starting monitoring with the same model that will be used
|
|
50
|
+
const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
|
|
51
|
+
if (!isToolConnected) {
|
|
52
|
+
await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
|
|
53
|
+
await safeExit(1, 'Error occurred');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { skipped: false };
|
|
57
|
+
}
|
|
@@ -47,6 +47,10 @@ async function getCommandStreamDollar() {
|
|
|
47
47
|
}
|
|
48
48
|
// Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
|
|
49
49
|
export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
|
|
50
|
+
// Issue #2175: the `$` output parsers live in their own module to keep this file
|
|
51
|
+
// under the 1350-line warning threshold. Re-exported so importers are unaffected.
|
|
52
|
+
import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
|
|
53
|
+
export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
|
|
50
54
|
// Valid isolation backends
|
|
51
55
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
52
56
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
@@ -59,17 +63,6 @@ const DOCKER_ISOLATION_SHELL = 'sh';
|
|
|
59
63
|
const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
|
|
60
64
|
// Docker-only start gate used to capture the container writable-layer baseline before the task command begins cloning or generating files. The parent releases the gate immediately after `docker inspect --size`; the fallback keeps the task from hanging forever if the parent exits at the wrong time.
|
|
61
65
|
const DOCKER_START_GATE_WAIT_TENTHS = 300;
|
|
62
|
-
// Sentinel start-command's detached docker logger records when it cannot capture the container's real exit code. A terminal `$ --status` carrying this value is ambiguous — the container may still be running — so we cross-check it against a live `docker inspect` before concluding the session finished. See #1939. The upstream emission of this premature sentinel was fixed in start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images now pin; this cross-check is retained as defense-in-depth so an older `$` on an operator's PATH cannot resurrect the bug.
|
|
63
|
-
const DOCKER_UNKNOWN_EXIT_CODE = -1;
|
|
64
|
-
function normalizeProcessIds(value) {
|
|
65
|
-
if (!value || typeof value !== 'object') return {};
|
|
66
|
-
const out = {};
|
|
67
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
68
|
-
const number = Number(raw);
|
|
69
|
-
if (Number.isInteger(number) && number > 0) out[key] = number;
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
66
|
function normalizeTool(tool) {
|
|
74
67
|
return String(tool || 'claude')
|
|
75
68
|
.trim()
|
|
@@ -269,233 +262,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
269
262
|
export function generateSessionId() {
|
|
270
263
|
return crypto.randomUUID();
|
|
271
264
|
}
|
|
272
|
-
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
273
|
-
/**
|
|
274
|
-
* Extract start-command's own execution UUID from a launch banner.
|
|
275
|
-
*
|
|
276
|
-
* Issue #2154: an isolated task has two UUIDs. Hive Mind generates the session
|
|
277
|
-
* name and passes it as `--session` (it also becomes the container name);
|
|
278
|
-
* start-command mints a separate execution UUID and prints it as the `session`
|
|
279
|
-
* field of its launch banner:
|
|
280
|
-
*
|
|
281
|
-
* ```
|
|
282
|
-
* │ session edc7b051-e12f-4f7b-b677-c885f3208407
|
|
283
|
-
* │ container 0a3627ef-f1f1-4801-a073-3678b9453db7
|
|
284
|
-
* ```
|
|
285
|
-
*
|
|
286
|
-
* `$ --list` shows the execution UUID, while Telegram and the logs showed the
|
|
287
|
-
* session UUID, so the two views could not be joined — which is why three
|
|
288
|
-
* refused tasks and two healthy ones looked equally unaccounted for. Returning
|
|
289
|
-
* it lets the caller record both.
|
|
290
|
-
*
|
|
291
|
-
* Only a well-formed UUID is returned; a banner we do not recognise yields
|
|
292
|
-
* null rather than a guess, because a wrong correlation is worse than none.
|
|
293
|
-
*
|
|
294
|
-
* @param {string} output - Raw stdout from the detached `$` launch
|
|
295
|
-
* @returns {string|null}
|
|
296
|
-
*/
|
|
297
|
-
export function parseStartCommandExecutionUuid(output) {
|
|
298
|
-
const raw = (output || '').trim();
|
|
299
|
-
if (!raw) return null;
|
|
300
|
-
try {
|
|
301
|
-
const parsed = JSON.parse(raw);
|
|
302
|
-
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
303
|
-
const uuid = data?.uuid || data?.session || null;
|
|
304
|
-
if (typeof uuid === 'string' && UUID_PATTERN.test(uuid.trim())) return uuid.trim();
|
|
305
|
-
} catch {
|
|
306
|
-
// Human-readable banner — fall through.
|
|
307
|
-
}
|
|
308
|
-
// The banner is box-drawn (`│ session <uuid>`); tolerate the prefix, an
|
|
309
|
-
// ASCII `|`, or no prefix at all.
|
|
310
|
-
const match = raw.match(/^[\s│|]*session\s+([^\s]+)\s*$/im);
|
|
311
|
-
const candidate = match?.[1]?.trim();
|
|
312
|
-
return candidate && UUID_PATTERN.test(candidate) ? candidate : null;
|
|
313
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* Parse output from `$ --status <session>`.
|
|
316
|
-
*
|
|
317
|
-
* start-command versions used in the wild may return JSON when
|
|
318
|
-
* `--output-format json` is supported, or human-readable key/value text.
|
|
319
|
-
* Keep the parser tolerant so completion monitoring survives either format.
|
|
320
|
-
*
|
|
321
|
-
* @param {string} output - Raw stdout from `$ --status`
|
|
322
|
-
* @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
|
|
323
|
-
*/
|
|
324
|
-
export function parseSessionStatusOutput(output) {
|
|
325
|
-
const raw = (output || '').trim();
|
|
326
|
-
if (!raw) {
|
|
327
|
-
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
|
|
328
|
-
}
|
|
329
|
-
const normalizeBooleanField = value => {
|
|
330
|
-
if (typeof value === 'boolean') return value;
|
|
331
|
-
if (value === null || value === undefined) return null;
|
|
332
|
-
const normalized = String(value).trim().toLowerCase();
|
|
333
|
-
if (['true', '1', 'yes'].includes(normalized)) return true;
|
|
334
|
-
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
335
|
-
return null;
|
|
336
|
-
};
|
|
337
|
-
try {
|
|
338
|
-
const parsed = JSON.parse(raw);
|
|
339
|
-
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
340
|
-
// start-command (link-foundation/start) reports the isolation backend at `options.isolated` in both JSON and links-notation output. Older hypothetical layouts used `options.isolation` or a top-level `isolation` field — keep accepting all three so we are tolerant of future renames. See https://github.com/link-assistant/hive-mind/issues/1700.
|
|
341
|
-
const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
|
|
342
|
-
const topPid = Number(data?.pid);
|
|
343
|
-
const processIds = normalizeProcessIds(data?.processIds);
|
|
344
|
-
if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
|
|
345
|
-
return {
|
|
346
|
-
exists: true,
|
|
347
|
-
uuid: data?.uuid || null,
|
|
348
|
-
status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
|
|
349
|
-
exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
|
|
350
|
-
startTime: data?.startTime || null,
|
|
351
|
-
endTime: data?.endTime || null,
|
|
352
|
-
currentTime: data?.currentTime || null,
|
|
353
|
-
logPath: data?.logPath || null,
|
|
354
|
-
command: data?.command || null,
|
|
355
|
-
isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
|
|
356
|
-
workingDirectory: data?.workingDirectory || null,
|
|
357
|
-
sessionName: data?.sessionName || data?.options?.sessionName || null,
|
|
358
|
-
processIds,
|
|
359
|
-
oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
|
|
360
|
-
raw,
|
|
361
|
-
};
|
|
362
|
-
} catch {
|
|
363
|
-
// Fall through to text parsing.
|
|
364
|
-
}
|
|
365
|
-
const firstLine =
|
|
366
|
-
raw
|
|
367
|
-
.split('\n')
|
|
368
|
-
.find(line => line.trim() && !line.includes(' '))
|
|
369
|
-
?.trim() || null;
|
|
370
|
-
const readField = name => {
|
|
371
|
-
const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
|
|
372
|
-
return match ? match[1].trim() : null;
|
|
373
|
-
};
|
|
374
|
-
const readBooleanField = name => normalizeBooleanField(readField(name));
|
|
375
|
-
const status = readField('status')?.toLowerCase() || null;
|
|
376
|
-
const exitCodeText = readField('exitCode');
|
|
377
|
-
// `start-command` links-notation output nests the isolation backend under `options` as `isolated <backend>` (not `isolation`). The leading indent varies by depth, but `readField` is anchored with `^\s*` which already matches indented lines. Older code only looked for `isolation`, which
|
|
378
|
-
// returned null for every real session and made /log + /terminal_watch
|
|
379
|
-
// reject screen/tmux/docker sessions. See issue #1700.
|
|
380
|
-
const isolationText = readField('isolated') || readField('isolation');
|
|
381
|
-
const processIds = {};
|
|
382
|
-
for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
|
|
383
|
-
const value = readField(name);
|
|
384
|
-
const number = Number(value);
|
|
385
|
-
if (Number.isInteger(number) && number > 0) processIds[name] = number;
|
|
386
|
-
}
|
|
387
|
-
return {
|
|
388
|
-
exists: Boolean(status || firstLine),
|
|
389
|
-
uuid: readField('uuid') || firstLine,
|
|
390
|
-
status,
|
|
391
|
-
exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
|
|
392
|
-
startTime: readField('startTime'),
|
|
393
|
-
endTime: readField('endTime'),
|
|
394
|
-
currentTime: readField('currentTime'),
|
|
395
|
-
logPath: readField('logPath'),
|
|
396
|
-
command: readField('command'),
|
|
397
|
-
isolation: isolationText?.toLowerCase() || null,
|
|
398
|
-
workingDirectory: readField('workingDirectory'),
|
|
399
|
-
sessionName: readField('sessionName'),
|
|
400
|
-
processIds,
|
|
401
|
-
oomKilled: readBooleanField('oomKilled'),
|
|
402
|
-
raw,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Decide whether a detached-docker exit code is "unknown" (not a real result).
|
|
407
|
-
*
|
|
408
|
-
* start-command's detached docker logger writes the exit-code footer only after
|
|
409
|
-
* `docker logs -f` returns, capturing the real code via `docker inspect`. When
|
|
410
|
-
* it cannot capture one it records the sentinel `-1`. A `$ --status` that
|
|
411
|
-
* reports a terminal status ("executed") while still carrying that sentinel — or
|
|
412
|
-
* no exit code at all — is therefore ambiguous: the container may actually still
|
|
413
|
-
* be running. Callers treat such a status as provisional and cross-check the
|
|
414
|
-
* live container before declaring the session finished. See issue #1939.
|
|
415
|
-
*
|
|
416
|
-
* @param {number|null|undefined} exitCode
|
|
417
|
-
* @returns {boolean} True when the exit code carries no real result.
|
|
418
|
-
*/
|
|
419
|
-
export function isUnknownDockerExitCode(exitCode) {
|
|
420
|
-
return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
|
|
421
|
-
}
|
|
422
|
-
export function shouldFallbackToScreenStatus(statusResult) {
|
|
423
|
-
return !statusResult?.exists || !statusResult?.status;
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Parse the footer start-command appends to every execution log when the wrapped
|
|
427
|
-
* command exits. The footer is authoritative about the terminal exit code even
|
|
428
|
-
* when `$ --status` is wrong: start-command writes it from the command's own
|
|
429
|
-
* `close`/`exited` handler, so its presence proves the command terminated.
|
|
430
|
-
*
|
|
431
|
-
* Footer shape (see start-command spawn-helpers.js):
|
|
432
|
-
*
|
|
433
|
-
* ==================================================
|
|
434
|
-
* Finished: 2026-06-14 19:10:49.822
|
|
435
|
-
* Exit Code: 137
|
|
436
|
-
*
|
|
437
|
-
* Issue #1927: start-command's `enrichDetachedStatus` can flip a completed
|
|
438
|
-
* `executed/137` record back to `executing` (nulling the exit code) when a
|
|
439
|
-
* lingering shell keeps the screen session alive — so `$ --status` reports
|
|
440
|
-
* `executing` forever and the bot never notices the kill. Reading this footer
|
|
441
|
-
* lets hive-mind detect the real terminal exit regardless of that flip.
|
|
442
|
-
*
|
|
443
|
-
* @param {string} text - Log text (typically the tail of the log file)
|
|
444
|
-
* @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
|
|
445
|
-
*/
|
|
446
|
-
export function parseSessionExitFooter(text) {
|
|
447
|
-
if (!text) return { finished: false, exitCode: null, endTime: null };
|
|
448
|
-
// Match the LAST footer block in the text (a re-run could append more than
|
|
449
|
-
// one). Anchor on the `=` separator so command output that merely prints
|
|
450
|
-
// "Exit Code: N" mid-stream is not mistaken for the footer.
|
|
451
|
-
const re = /={10,}\s*\r?\nFinished:\s*([^\r\n]+)\r?\nExit Code:\s*(-?\d+)/g;
|
|
452
|
-
let match;
|
|
453
|
-
let last = null;
|
|
454
|
-
while ((match = re.exec(text)) !== null) last = match;
|
|
455
|
-
if (!last) return { finished: false, exitCode: null, endTime: null };
|
|
456
|
-
return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Read the terminal exit code from the tail of a start-command execution log.
|
|
460
|
-
*
|
|
461
|
-
* Only the last `tailBytes` of the file are read (the footer lives at the end),
|
|
462
|
-
* so this is cheap even for multi-megabyte logs. Never throws — a missing or
|
|
463
|
-
* unreadable log yields `{ finished: false }`.
|
|
464
|
-
*
|
|
465
|
-
* @param {string} logPath
|
|
466
|
-
* @param {Object} [options]
|
|
467
|
-
* @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
|
|
468
|
-
* @param {number} [options.tailBytes=16384] - How many trailing bytes to scan
|
|
469
|
-
* @param {boolean} [options.verbose]
|
|
470
|
-
* @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
|
|
471
|
-
*/
|
|
472
|
-
export function readSessionExitFromLog(logPath, options = {}) {
|
|
473
|
-
const { fsImpl = fs, tailBytes = 16384, verbose = false } = options;
|
|
474
|
-
if (!logPath) return { finished: false, exitCode: null, endTime: null };
|
|
475
|
-
try {
|
|
476
|
-
const { size } = fsImpl.statSync(logPath);
|
|
477
|
-
if (!size) return { finished: false, exitCode: null, endTime: null };
|
|
478
|
-
const start = Math.max(0, size - tailBytes);
|
|
479
|
-
const length = size - start;
|
|
480
|
-
const buffer = Buffer.alloc(length);
|
|
481
|
-
const fd = fsImpl.openSync(logPath, 'r');
|
|
482
|
-
try {
|
|
483
|
-
fsImpl.readSync(fd, buffer, 0, length, start);
|
|
484
|
-
} finally {
|
|
485
|
-
fsImpl.closeSync(fd);
|
|
486
|
-
}
|
|
487
|
-
const result = parseSessionExitFooter(buffer.toString('utf8'));
|
|
488
|
-
if (verbose && result.finished) {
|
|
489
|
-
console.log(`[VERBOSE] isolation-runner: log footer for ${logPath} reports exit ${result.exitCode} (finished ${result.endTime})`);
|
|
490
|
-
}
|
|
491
|
-
return result;
|
|
492
|
-
} catch (error) {
|
|
493
|
-
if (verbose) {
|
|
494
|
-
console.log(`[VERBOSE] isolation-runner: could not read exit footer from ${logPath}: ${error.message}`);
|
|
495
|
-
}
|
|
496
|
-
return { finished: false, exitCode: null, endTime: null };
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
265
|
/**
|
|
500
266
|
* Find the `$` CLI binary path
|
|
501
267
|
* @returns {Promise<string|null>} Path to `$` binary or null
|
|
@@ -700,45 +466,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
700
466
|
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
|
|
701
467
|
}
|
|
702
468
|
}
|
|
703
|
-
/**
|
|
704
|
-
* Parse output from `$ --list --output-format json`.
|
|
705
|
-
*
|
|
706
|
-
* start-command may return a top-level array, or an object with an
|
|
707
|
-
* `executions`/`sessions` array. Each entry is normalized to the same shape used
|
|
708
|
-
* by {@link parseSessionStatusOutput} (uuid/status/exitCode/command/isolation/…).
|
|
709
|
-
* Tolerant of unknown layouts — anything unparseable yields an empty list.
|
|
710
|
-
*
|
|
711
|
-
* @param {string} output - Raw stdout from `$ --list`
|
|
712
|
-
* @returns {Array<{uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null}>}
|
|
713
|
-
*/
|
|
714
|
-
export function parseSessionListOutput(output) {
|
|
715
|
-
const raw = (output || '').trim();
|
|
716
|
-
if (!raw) return [];
|
|
717
|
-
let parsed;
|
|
718
|
-
try {
|
|
719
|
-
parsed = JSON.parse(raw);
|
|
720
|
-
} catch {
|
|
721
|
-
return [];
|
|
722
|
-
}
|
|
723
|
-
const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
724
|
-
return records
|
|
725
|
-
.map(data => {
|
|
726
|
-
if (!data || typeof data !== 'object') return null;
|
|
727
|
-
const isolationCandidate = (typeof data.isolation === 'string' && data.isolation) || (typeof data.options?.isolated === 'string' && data.options.isolated) || (typeof data.options?.isolation === 'string' && data.options.isolation) || null;
|
|
728
|
-
return {
|
|
729
|
-
uuid: data.uuid || data.session || data.sessionId || null,
|
|
730
|
-
status: typeof data.status === 'string' ? data.status.toLowerCase() : null,
|
|
731
|
-
exitCode: data.exitCode !== undefined && data.exitCode !== null ? Number(data.exitCode) : null,
|
|
732
|
-
startTime: data.startTime || null,
|
|
733
|
-
endTime: data.endTime || null,
|
|
734
|
-
command: data.command || null,
|
|
735
|
-
isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
|
|
736
|
-
workingDirectory: data.workingDirectory || null,
|
|
737
|
-
sessionName: data.sessionName || data.options?.sessionName || null,
|
|
738
|
-
};
|
|
739
|
-
})
|
|
740
|
-
.filter(Boolean);
|
|
741
|
-
}
|
|
742
469
|
/**
|
|
743
470
|
* List all executions known to start-command via `$ --list --output-format json`.
|
|
744
471
|
*
|