@triflux/remote 10.0.0-alpha.2 → 10.0.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/hub/index.mjs +1 -1
- package/hub/server.mjs +71 -52
- package/hub/team/conductor-mesh-bridge.mjs +121 -0
- package/hub/team/conductor.mjs +671 -671
- package/hub/team/headless.mjs +1156 -1151
- package/hub/team/remote-session.mjs +299 -0
- package/hub/team/swarm-hypervisor.mjs +60 -1
- package/hub/team/swarm-planner.mjs +4 -0
- package/hub/team/worktree-lifecycle.mjs +65 -8
- package/hub/team/wt-manager.mjs +407 -0
- package/hub/team/wt-templates.json +43 -0
- package/hub/workers/codex-mcp.mjs +507 -510
- package/package.json +11 -3
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// hub/team/remote-session.mjs — Remote session primitives for swarm integration
|
|
2
|
+
// Extracted from scripts/remote-spawn.mjs for reuse by swarm-hypervisor.
|
|
3
|
+
// Pure functions + SSH operations. No psmux, no WT, no CLI arg parsing.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join, posix as posixPath, win32 as win32Path } from 'node:path';
|
|
8
|
+
import { execSshWithRetry } from '@triflux/core/hub/lib/ssh-retry.mjs';
|
|
9
|
+
|
|
10
|
+
const REMOTE_ENV_TTL_MS = 86_400_000; // 24h
|
|
11
|
+
const REMOTE_STAGE_ROOT = 'tfx-remote';
|
|
12
|
+
const SAFE_HOST_RE = /^[a-zA-Z0-9._-]+$/;
|
|
13
|
+
|
|
14
|
+
// ── Shell quoting utilities ─────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function shellQuote(value) {
|
|
17
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function escapePwshSingleQuoted(value) {
|
|
21
|
+
return String(value).replace(/'/g, "''");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function escapePwshDoubleQuoted(value) {
|
|
25
|
+
return String(value).replace(/`/g, '``').replace(/"/g, '`"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeCommandPath(value) {
|
|
29
|
+
return String(value).replace(/\\/g, '/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Validation ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function validateHost(host) {
|
|
35
|
+
if (!host || !SAFE_HOST_RE.test(host)) {
|
|
36
|
+
throw new Error(`invalid host name: ${host}`);
|
|
37
|
+
}
|
|
38
|
+
return host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Remote environment probe ────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function parseProbeLines(text) {
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
text
|
|
46
|
+
.split(/\r?\n/u)
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((line) => {
|
|
50
|
+
const idx = line.indexOf('=');
|
|
51
|
+
return idx === -1 ? null : [line.slice(0, idx), line.slice(idx + 1)];
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizePwshProbeEnv(parsed) {
|
|
58
|
+
if (parsed.shell !== 'pwsh' || parsed.os !== 'win32') return null;
|
|
59
|
+
if (!parsed.home) return null;
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
|
|
62
|
+
home: parsed.home,
|
|
63
|
+
os: 'win32',
|
|
64
|
+
shell: 'pwsh',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizePosixProbeEnv(parsed) {
|
|
69
|
+
const os = parsed.os === 'darwin' ? 'darwin' : parsed.os === 'linux' ? 'linux' : null;
|
|
70
|
+
if (!os || !parsed.home) return null;
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
|
|
73
|
+
home: parsed.home,
|
|
74
|
+
os,
|
|
75
|
+
shell: parsed.shell === 'zsh' ? 'zsh' : 'bash',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function probeRemoteEnvViaPwsh(host) {
|
|
80
|
+
const command = [
|
|
81
|
+
"Write-Output 'shell=pwsh'",
|
|
82
|
+
'Write-Output "home=$env:USERPROFILE"',
|
|
83
|
+
'if (Test-Path "$env:USERPROFILE\\.local\\bin\\claude.exe") { Write-Output "claude=$env:USERPROFILE\\.local\\bin\\claude.exe" } elseif (Get-Command claude -ErrorAction SilentlyContinue) { Write-Output "claude=$((Get-Command claude).Source)" } else { Write-Output \'claude=notfound\' }',
|
|
84
|
+
'Write-Output "os=$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) ? \'win32\' : \'other\')"',
|
|
85
|
+
].join('; ');
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const output = execSshWithRetry([host, 'pwsh', '-NoProfile', '-Command', command], {
|
|
89
|
+
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
maxRetries: 2, baseDelayMs: 1000,
|
|
91
|
+
});
|
|
92
|
+
return normalizePwshProbeEnv(parseProbeLines(output));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function probeRemoteEnvViaPosix(host) {
|
|
99
|
+
const script = [
|
|
100
|
+
'echo shell=$(basename $SHELL)',
|
|
101
|
+
'echo home=$HOME',
|
|
102
|
+
'command -v claude >/dev/null 2>&1 && echo claude=$(command -v claude) || echo claude=notfound',
|
|
103
|
+
'echo os=$(uname -s | tr A-Z a-z)',
|
|
104
|
+
].join('\n');
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const output = execSshWithRetry([host, 'sh'], {
|
|
108
|
+
encoding: 'utf8', timeout: 15000, input: script,
|
|
109
|
+
maxRetries: 2, baseDelayMs: 1000,
|
|
110
|
+
});
|
|
111
|
+
return normalizePosixProbeEnv(parseProbeLines(output));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Cache ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function getEnvCachePath(host, cacheDir) {
|
|
120
|
+
return join(cacheDir, `${host}.json`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readEnvCache(host, cacheDir) {
|
|
124
|
+
const cachePath = getEnvCachePath(host, cacheDir);
|
|
125
|
+
if (!existsSync(cachePath)) return null;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
128
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isEnvCacheFresh(entry) {
|
|
135
|
+
return Boolean(
|
|
136
|
+
entry
|
|
137
|
+
&& typeof entry.cachedAt === 'number'
|
|
138
|
+
&& entry.env
|
|
139
|
+
&& (Date.now() - entry.cachedAt) < REMOTE_ENV_TTL_MS,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeEnvCache(host, env, cacheDir) {
|
|
144
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
145
|
+
writeFileSync(getEnvCachePath(host, cacheDir), JSON.stringify({ cachedAt: Date.now(), env }, null, 2), 'utf8');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Probe remote host environment (OS, shell, Claude path, home dir).
|
|
150
|
+
* Results are cached for 24h.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} host — SSH host
|
|
153
|
+
* @param {object} [opts]
|
|
154
|
+
* @param {boolean} [opts.force=false] — bypass cache
|
|
155
|
+
* @param {string} [opts.cacheDir] — cache directory (default: .omc/state/remote-env)
|
|
156
|
+
* @returns {Readonly<RemoteEnv>}
|
|
157
|
+
*/
|
|
158
|
+
export function probeRemoteEnv(host, opts = {}) {
|
|
159
|
+
validateHost(host);
|
|
160
|
+
const force = opts.force === true;
|
|
161
|
+
const cacheDir = opts.cacheDir || join('.omc', 'state', 'remote-env');
|
|
162
|
+
|
|
163
|
+
if (!force) {
|
|
164
|
+
const cached = readEnvCache(host, cacheDir);
|
|
165
|
+
if (isEnvCacheFresh(cached)) return cached.env;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pwshEnv = probeRemoteEnvViaPwsh(host);
|
|
169
|
+
if (pwshEnv) { writeEnvCache(host, pwshEnv, cacheDir); return pwshEnv; }
|
|
170
|
+
|
|
171
|
+
const posixEnv = probeRemoteEnvViaPosix(host);
|
|
172
|
+
if (posixEnv) { writeEnvCache(host, posixEnv, cacheDir); return posixEnv; }
|
|
173
|
+
|
|
174
|
+
throw new Error(`remote probe failed for ${host}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Remote directory resolution ─────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function isWindowsAbsolutePath(value) {
|
|
180
|
+
return /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith('\\\\');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a directory path on a remote host.
|
|
185
|
+
* Handles ~ expansion and OS-specific path normalization.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} dir — requested directory (or empty for home)
|
|
188
|
+
* @param {RemoteEnv} env
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function resolveRemoteDir(dir, env) {
|
|
192
|
+
const requestedDir = dir || env.home;
|
|
193
|
+
|
|
194
|
+
if (env.os === 'win32') {
|
|
195
|
+
const winDir = requestedDir.replace(/\//g, '\\');
|
|
196
|
+
if (winDir === '~') return env.home;
|
|
197
|
+
if (/^~[\\/]/u.test(winDir)) return win32Path.join(env.home, winDir.slice(2));
|
|
198
|
+
if (isWindowsAbsolutePath(winDir)) return winDir;
|
|
199
|
+
return win32Path.join(env.home, winDir);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (requestedDir === '~') return env.home;
|
|
203
|
+
if (requestedDir.startsWith('~/')) return posixPath.join(env.home, requestedDir.slice(2));
|
|
204
|
+
if (requestedDir.startsWith('/')) return requestedDir;
|
|
205
|
+
return posixPath.join(env.home, requestedDir);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Remote file staging ─────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the remote staging directory path.
|
|
212
|
+
* @param {RemoteEnv} env
|
|
213
|
+
* @param {string} stageId
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function resolveRemoteStageDir(env, stageId) {
|
|
217
|
+
return `${normalizeCommandPath(env.home)}/${REMOTE_STAGE_ROOT}/${stageId}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Ensure the remote staging directory exists via SSH.
|
|
222
|
+
* @param {string} host
|
|
223
|
+
* @param {RemoteEnv} env
|
|
224
|
+
* @param {string} remoteStageDir
|
|
225
|
+
*/
|
|
226
|
+
export function ensureRemoteStageDir(host, env, remoteStageDir) {
|
|
227
|
+
if (env.os === 'win32') {
|
|
228
|
+
const safePath = escapePwshSingleQuoted(remoteStageDir);
|
|
229
|
+
execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', `New-Item -ItemType Directory -Path '${safePath}' -Force | Out-Null`], { timeout: 10000, stdio: 'pipe' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
execFileSync('ssh', [host, 'sh', '-lc', `mkdir -p ${shellQuote(remoteStageDir)}`], { timeout: 10000, stdio: 'pipe' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Upload a file to remote host via scp.
|
|
237
|
+
* @param {string} host
|
|
238
|
+
* @param {string} localPath
|
|
239
|
+
* @param {string} remotePath
|
|
240
|
+
*/
|
|
241
|
+
export function uploadFileToRemote(host, localPath, remotePath) {
|
|
242
|
+
execFileSync('scp', [localPath, `${host}:${remotePath}`], { timeout: 15000, stdio: 'pipe' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Stage local files on a remote host for prompt delivery.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} host
|
|
249
|
+
* @param {RemoteEnv} env
|
|
250
|
+
* @param {Array<{ localPath: string }>} transferCandidates
|
|
251
|
+
* @param {string} stageId
|
|
252
|
+
* @returns {{ remoteStageDir: string|null, stagedFiles: Array<{ localPath: string, remotePath: string }> }}
|
|
253
|
+
*/
|
|
254
|
+
export function stageRemotePromptFiles(host, env, transferCandidates, stageId) {
|
|
255
|
+
if (!transferCandidates || transferCandidates.length === 0) {
|
|
256
|
+
return { remoteStageDir: null, stagedFiles: [] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const remoteStageDir = resolveRemoteStageDir(env, stageId);
|
|
260
|
+
ensureRemoteStageDir(host, env, remoteStageDir);
|
|
261
|
+
|
|
262
|
+
const basenameCounts = new Map();
|
|
263
|
+
const stagedFiles = transferCandidates.map((candidate) => {
|
|
264
|
+
const fileName = basename(candidate.localPath);
|
|
265
|
+
const count = (basenameCounts.get(fileName) || 0) + 1;
|
|
266
|
+
basenameCounts.set(fileName, count);
|
|
267
|
+
const stagedName = count === 1 ? fileName : `${count}-${fileName}`;
|
|
268
|
+
const remotePath = `${remoteStageDir}/${stagedName}`;
|
|
269
|
+
uploadFileToRemote(host, candidate.localPath, remotePath);
|
|
270
|
+
return { ...candidate, remotePath };
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return { remoteStageDir, stagedFiles };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Execute a git command on a remote host via SSH.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} host
|
|
280
|
+
* @param {RemoteEnv} env
|
|
281
|
+
* @param {string[]} gitArgs — git subcommand + args
|
|
282
|
+
* @param {string} cwd — remote working directory
|
|
283
|
+
* @returns {string} stdout
|
|
284
|
+
*/
|
|
285
|
+
export function remoteGit(host, env, gitArgs, cwd) {
|
|
286
|
+
const gitCmd = ['git', ...gitArgs].map((a) => shellQuote(a)).join(' ');
|
|
287
|
+
|
|
288
|
+
if (env.os === 'win32') {
|
|
289
|
+
const cdPath = escapePwshSingleQuoted(cwd);
|
|
290
|
+
const command = `Set-Location '${cdPath}'; ${gitCmd}`;
|
|
291
|
+
return execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', command], {
|
|
292
|
+
encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
293
|
+
}).trim();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return execFileSync('ssh', [host, 'sh', '-lc', `cd ${shellQuote(cwd)} && ${gitCmd}`], {
|
|
297
|
+
encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
298
|
+
}).trim();
|
|
299
|
+
}
|
|
@@ -18,6 +18,9 @@ import { execSync } from 'node:child_process';
|
|
|
18
18
|
import { createConductor, STATES } from './conductor.mjs';
|
|
19
19
|
import { createSwarmLocks } from './swarm-locks.mjs';
|
|
20
20
|
import { createEventLog } from './event-log.mjs';
|
|
21
|
+
import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
|
|
22
|
+
import { fetchRemoteShard } from './worktree-lifecycle.mjs';
|
|
23
|
+
import { getHostConfig } from '@triflux/core/hub/lib/ssh-command.mjs';
|
|
21
24
|
|
|
22
25
|
// ── Swarm states ──────────────────────────────────────────────
|
|
23
26
|
|
|
@@ -103,19 +106,51 @@ export function createSwarmHypervisor(opts) {
|
|
|
103
106
|
// ── Worker lifecycle ────────────────────────────────────────
|
|
104
107
|
|
|
105
108
|
function buildSessionConfig(shard) {
|
|
106
|
-
|
|
109
|
+
const config = {
|
|
107
110
|
id: `swarm-${shard.name}-${Date.now()}`,
|
|
108
111
|
agent: shard.agent,
|
|
109
112
|
prompt: shard.prompt,
|
|
110
113
|
workdir,
|
|
111
114
|
mcpServers: shard.mcp,
|
|
112
115
|
};
|
|
116
|
+
|
|
117
|
+
// Remote shard: add conductor remote fields
|
|
118
|
+
if (shard.host && shard._remoteEnv) {
|
|
119
|
+
const remoteDir = resolveRemoteDir(workdir, shard._remoteEnv);
|
|
120
|
+
return {
|
|
121
|
+
...config,
|
|
122
|
+
remote: true,
|
|
123
|
+
host: shard.host,
|
|
124
|
+
sessionName: `swarm-${shard.name}-${Date.now()}`,
|
|
125
|
+
paneTarget: `swarm-${shard.name}-${Date.now()}:0.0`,
|
|
126
|
+
workdir: remoteDir,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return config;
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
function launchShard(shard, isRedundant = false) {
|
|
116
134
|
const shardLogsDir = join(logsDir, isRedundant ? `${shard.name}-redundant` : shard.name);
|
|
117
135
|
mkdirSync(shardLogsDir, { recursive: true });
|
|
118
136
|
|
|
137
|
+
// Remote shard: probe environment before conductor creation
|
|
138
|
+
if (shard.host && !shard._remoteEnv) {
|
|
139
|
+
try {
|
|
140
|
+
shard._remoteEnv = probeRemoteEnv(shard.host);
|
|
141
|
+
if (!shard._remoteEnv.claudePath) {
|
|
142
|
+
eventLog.append('remote_probe_no_claude', { shard: shard.name, host: shard.host });
|
|
143
|
+
failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `claude not found on ${shard.host}` });
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
eventLog.append('remote_probe_ok', { shard: shard.name, host: shard.host, env: shard._remoteEnv });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
eventLog.append('remote_probe_failed', { shard: shard.name, host: shard.host, error: err.message });
|
|
149
|
+
failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `remote probe failed: ${err.message}` });
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
const conductor = createConductor({
|
|
120
155
|
logsDir: shardLogsDir,
|
|
121
156
|
maxRestarts,
|
|
@@ -150,6 +185,8 @@ export function createSwarmHypervisor(opts) {
|
|
|
150
185
|
sessionId: sessionConfig.id,
|
|
151
186
|
isRedundant,
|
|
152
187
|
files: shard.files,
|
|
188
|
+
remote: Boolean(shard.host),
|
|
189
|
+
host: shard.host || null,
|
|
153
190
|
});
|
|
154
191
|
|
|
155
192
|
const entry = { conductor, shardConfig: shard, sessionConfig, startedAt: Date.now() };
|
|
@@ -302,6 +339,28 @@ export function createSwarmHypervisor(opts) {
|
|
|
302
339
|
const worker = workers.get(shardName);
|
|
303
340
|
if (!worker) continue;
|
|
304
341
|
|
|
342
|
+
// Fetch remote shard branch to local (push-blocked hosts like Ultra4)
|
|
343
|
+
const shard = plan.shards.find((s) => s.name === shardName);
|
|
344
|
+
if (shard?.host && shard._remoteEnv) {
|
|
345
|
+
const hostConfig = getHostConfig(shard.host, config.rootDir);
|
|
346
|
+
const sshUser = hostConfig?.ssh_user || shard.host;
|
|
347
|
+
const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
|
|
348
|
+
const fetchResult = await fetchRemoteShard({
|
|
349
|
+
host: shard.host,
|
|
350
|
+
sshUser,
|
|
351
|
+
remoteRepoPath,
|
|
352
|
+
branchName: worker.branchName || `swarm/${config.runId}/${shardName}`,
|
|
353
|
+
rootDir: config.rootDir || process.cwd(),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!fetchResult.ok) {
|
|
357
|
+
eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
|
|
358
|
+
integrationFailures.push(shardName);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
|
|
362
|
+
}
|
|
363
|
+
|
|
305
364
|
// Read shard output log for changed files
|
|
306
365
|
const changedFiles = detectChangedFiles(shardName, worker);
|
|
307
366
|
|
|
@@ -24,6 +24,7 @@ const SHARD_DEFAULTS = Object.freeze({
|
|
|
24
24
|
depends: [],
|
|
25
25
|
critical: false,
|
|
26
26
|
prompt: '',
|
|
27
|
+
host: '',
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -105,6 +106,9 @@ export function parseShards(content) {
|
|
|
105
106
|
case 'critical':
|
|
106
107
|
current.critical = /^(true|yes|1)$/i.test(value);
|
|
107
108
|
break;
|
|
109
|
+
case 'host':
|
|
110
|
+
current.host = value;
|
|
111
|
+
break;
|
|
108
112
|
case 'prompt':
|
|
109
113
|
if (value && !value.startsWith('|')) {
|
|
110
114
|
current.prompt = value;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// hub/team/worktree-lifecycle.mjs — Git worktree lifecycle management
|
|
2
2
|
// Replaces shell commands in tfx-codex-swarm Step 5 + merge-worktree Phase 6.
|
|
3
3
|
// Convention: .codex-swarm/wt-{slug} paths, swarm/{runId}/{slug} branches.
|
|
4
|
+
// Remote support: host option → SSH-based git operations via remote-session.mjs.
|
|
4
5
|
|
|
5
6
|
import { execFile } from 'node:child_process';
|
|
6
7
|
import { resolve, normalize } from 'node:path';
|
|
7
8
|
import { mkdir, rm, access } from 'node:fs/promises';
|
|
9
|
+
import { remoteGit, validateHost } from './remote-session.mjs';
|
|
8
10
|
|
|
9
11
|
const SWARM_ROOT = '.codex-swarm';
|
|
10
12
|
const SLEEP_MS = 2000; // WT race-guard (MEMORY.md: wt-attach-spacing)
|
|
@@ -39,39 +41,58 @@ function normPath(p) {
|
|
|
39
41
|
* @param {string} opts.runId — swarm run ID
|
|
40
42
|
* @param {string} [opts.rootDir=process.cwd()] — repo root
|
|
41
43
|
* @param {string} [opts.baseBranch='main'] — base branch to branch from
|
|
42
|
-
* @
|
|
44
|
+
* @param {string} [opts.host] — SSH host for remote worktree creation
|
|
45
|
+
* @param {object} [opts.remoteEnv] — remote environment from probeRemoteEnv()
|
|
46
|
+
* @returns {Promise<{ worktreePath: string, branchName: string, remote: boolean }>}
|
|
43
47
|
*/
|
|
44
|
-
export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), baseBranch = 'main' }) {
|
|
45
|
-
const wtDir = resolve(rootDir, SWARM_ROOT, `wt-${slug}`);
|
|
48
|
+
export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), baseBranch = 'main', host, remoteEnv }) {
|
|
46
49
|
const branchName = `swarm/${runId}/${slug}`;
|
|
47
50
|
|
|
51
|
+
// ── Remote path: SSH-based worktree creation ──
|
|
52
|
+
if (host && remoteEnv) {
|
|
53
|
+
const remoteRoot = rootDir.replace(/\\/g, '/');
|
|
54
|
+
const remoteWtDir = `${remoteRoot}/${SWARM_ROOT}/wt-${slug}`;
|
|
55
|
+
|
|
56
|
+
try { remoteGit(host, remoteEnv, ['worktree', 'prune'], remoteRoot); } catch { /* best-effort */ }
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, '-b', branchName, baseBranch], remoteRoot);
|
|
60
|
+
} catch {
|
|
61
|
+
try {
|
|
62
|
+
remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, branchName], remoteRoot);
|
|
63
|
+
} catch { /* already exists — acceptable */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { worktreePath: remoteWtDir, branchName, remote: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Local path (existing logic) ──
|
|
70
|
+
const wtDir = resolve(rootDir, SWARM_ROOT, `wt-${slug}`);
|
|
71
|
+
|
|
48
72
|
await mkdir(resolve(rootDir, SWARM_ROOT), { recursive: true });
|
|
49
73
|
|
|
50
74
|
// Check if worktree already exists
|
|
51
75
|
try {
|
|
52
76
|
await access(wtDir);
|
|
53
|
-
// Already exists — verify it's a valid worktree
|
|
54
77
|
await git(['rev-parse', '--is-inside-work-tree'], wtDir);
|
|
55
|
-
return { worktreePath: normPath(wtDir), branchName };
|
|
78
|
+
return { worktreePath: normPath(wtDir), branchName, remote: false };
|
|
56
79
|
} catch {
|
|
57
80
|
// Doesn't exist or invalid — create fresh
|
|
58
81
|
}
|
|
59
82
|
|
|
60
|
-
// Clean up stale references
|
|
61
83
|
try {
|
|
62
84
|
await git(['worktree', 'prune'], rootDir);
|
|
63
85
|
} catch { /* best-effort */ }
|
|
64
86
|
|
|
65
87
|
await sleep(SLEEP_MS);
|
|
66
88
|
|
|
67
|
-
// Try create with new branch, fallback to existing branch
|
|
68
89
|
try {
|
|
69
90
|
await git(['worktree', 'add', wtDir, '-b', branchName, baseBranch], rootDir);
|
|
70
91
|
} catch {
|
|
71
92
|
await git(['worktree', 'add', wtDir, branchName], rootDir);
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
return { worktreePath: normPath(wtDir), branchName };
|
|
95
|
+
return { worktreePath: normPath(wtDir), branchName, remote: false };
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
/**
|
|
@@ -170,3 +191,39 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
|
|
|
170
191
|
try { await git(['branch', '-D', branchName], rootDir); } catch { /* may not exist */ }
|
|
171
192
|
}
|
|
172
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetch a remote shard's branch to the local repo via SSH.
|
|
197
|
+
* Workaround for hosts that cannot push to GitHub (e.g. Ultra4).
|
|
198
|
+
*
|
|
199
|
+
* Flow: add temp remote → fetch branch → remove remote.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {string} opts.host — SSH host (e.g. "ultra4")
|
|
203
|
+
* @param {string} opts.sshUser — SSH user (e.g. "SSAFY")
|
|
204
|
+
* @param {string} opts.remoteRepoPath — absolute path on remote (e.g. "/c/Users/SSAFY/Desktop/Projects/cli/triflux")
|
|
205
|
+
* @param {string} opts.branchName — branch to fetch (e.g. "swarm/run123/auth")
|
|
206
|
+
* @param {string} [opts.rootDir=process.cwd()] — local repo root
|
|
207
|
+
* @returns {Promise<{ ok: boolean, localRef?: string, error?: string }>}
|
|
208
|
+
*/
|
|
209
|
+
export async function fetchRemoteShard({ host, sshUser, remoteRepoPath, branchName, rootDir = process.cwd() }) {
|
|
210
|
+
validateHost(host);
|
|
211
|
+
|
|
212
|
+
const remoteName = `_swarm-${host}-${Date.now()}`;
|
|
213
|
+
const sshUrl = `ssh://${sshUser}@${host}${remoteRepoPath}`;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await git(['remote', 'add', remoteName, sshUrl], rootDir);
|
|
217
|
+
|
|
218
|
+
await git(['fetch', remoteName, branchName, '--no-tags'], rootDir);
|
|
219
|
+
|
|
220
|
+
const localRef = `${remoteName}/${branchName}`;
|
|
221
|
+
const headCommit = await git(['rev-parse', `FETCH_HEAD`], rootDir);
|
|
222
|
+
|
|
223
|
+
return { ok: true, localRef, headCommit };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { ok: false, error: err.message };
|
|
226
|
+
} finally {
|
|
227
|
+
try { await git(['remote', 'remove', remoteName], rootDir); } catch { /* cleanup */ }
|
|
228
|
+
}
|
|
229
|
+
}
|