groove-dev 0.27.157 → 0.27.161
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/CLAUDE.md +7 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +130 -2
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +68 -60
- package/node_modules/@groove-dev/gui/dist/assets/index-DpRdb7o1.js +1020 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -2
- package/node_modules/@groove-dev/gui/src/app.css +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -8
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +17 -2
- package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +19 -7
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +122 -17
- package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +69 -38
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +75 -30
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/process.js +130 -2
- package/packages/daemon/src/tunnel-manager.js +68 -60
- package/packages/gui/dist/assets/index-DpRdb7o1.js +1020 -0
- package/packages/gui/dist/assets/index-Dzofq3wS.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -2
- package/packages/gui/src/app.css +2 -2
- package/packages/gui/src/components/agents/agent-feed.jsx +8 -8
- package/packages/gui/src/components/dashboard/cache-ring.jsx +2 -2
- package/packages/gui/src/components/dashboard/token-chart.jsx +2 -2
- package/packages/gui/src/components/editor/terminal.jsx +1 -1
- package/packages/gui/src/components/layout/welcome-splash.jsx +17 -2
- package/packages/gui/src/components/network/activity-chart.jsx +4 -4
- package/packages/gui/src/components/network/performance-dashboard.jsx +1 -1
- package/packages/gui/src/components/settings/quick-connect.jsx +19 -7
- package/packages/gui/src/components/settings/ssh-wizard.jsx +122 -17
- package/packages/gui/src/stores/groove.js +9 -1
- package/packages/gui/src/stores/slices/agents-slice.js +69 -38
- package/packages/gui/src/views/memory.jsx +75 -30
- package/node_modules/@fontsource-variable/jetbrains-mono/CHANGELOG.md +0 -2
- package/node_modules/@fontsource-variable/jetbrains-mono/LICENSE +0 -93
- package/node_modules/@fontsource-variable/jetbrains-mono/README.md +0 -48
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-italic.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2 +0 -0
- package/node_modules/@fontsource-variable/jetbrains-mono/index.css +0 -59
- package/node_modules/@fontsource-variable/jetbrains-mono/metadata.json +0 -29
- package/node_modules/@fontsource-variable/jetbrains-mono/package.json +0 -47
- package/node_modules/@fontsource-variable/jetbrains-mono/scss/metadata.scss +0 -46
- package/node_modules/@fontsource-variable/jetbrains-mono/scss/mixins.scss +0 -193
- package/node_modules/@fontsource-variable/jetbrains-mono/unicode.json +0 -8
- package/node_modules/@fontsource-variable/jetbrains-mono/wght-italic.css +0 -59
- package/node_modules/@fontsource-variable/jetbrains-mono/wght.css +0 -59
- package/node_modules/@groove-dev/gui/dist/assets/index-B6taUF7J.js +0 -1015
- package/node_modules/@groove-dev/gui/dist/assets/index-BAM0QzR0.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/packages/gui/dist/assets/index-B6taUF7J.js +0 -1015
- package/packages/gui/dist/assets/index-BAM0QzR0.css +0 -1
- package/packages/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/packages/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/packages/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/packages/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/packages/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
package/CLAUDE.md
CHANGED
|
@@ -295,3 +295,10 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
|
|
|
295
295
|
- Dashboard: routing donut, cache panel, context health gauges
|
|
296
296
|
- Monitor/QC agent mode (stay active, loop)
|
|
297
297
|
- Distribution: demo video, HN launch, Twitter content
|
|
298
|
+
|
|
299
|
+
<!-- GROOVE:START -->
|
|
300
|
+
## GROOVE Orchestration (auto-injected)
|
|
301
|
+
Active agents: 0
|
|
302
|
+
See AGENTS_REGISTRY.md for full agent state.
|
|
303
|
+
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
304
|
+
<!-- GROOVE:END -->
|
|
@@ -507,6 +507,7 @@ export class ProcessManager {
|
|
|
507
507
|
|
|
508
508
|
if (finalStatus === 'completed' && agent.role === 'planner') {
|
|
509
509
|
this._extractRecommendedTeam(agent, logPath);
|
|
510
|
+
this._consumeRecommendedTeamAutonomous(agent);
|
|
510
511
|
this._handleReviewComplete(agent);
|
|
511
512
|
}
|
|
512
513
|
|
|
@@ -534,7 +535,7 @@ export class ProcessManager {
|
|
|
534
535
|
|
|
535
536
|
this._checkPhase2(agent.id);
|
|
536
537
|
|
|
537
|
-
if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId) {
|
|
538
|
+
if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId && agent.metadata?.isQcPhase2) {
|
|
538
539
|
this._triggerReview(agent);
|
|
539
540
|
}
|
|
540
541
|
|
|
@@ -1755,6 +1756,114 @@ For normal file edits within your scope, proceed without review.
|
|
|
1755
1756
|
} catch { /* best effort */ }
|
|
1756
1757
|
}
|
|
1757
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* Daemon-autonomous consumption of recommended-team.json.
|
|
1761
|
+
* If the file exists and the GUI hasn't already consumed it (no _pendingPhase2
|
|
1762
|
+
* for this team), broadcast a notification so the GUI picks it up on next tick.
|
|
1763
|
+
* This closes the race where GUI polling stops before the file is written.
|
|
1764
|
+
*/
|
|
1765
|
+
_consumeRecommendedTeamAutonomous(agent) {
|
|
1766
|
+
try {
|
|
1767
|
+
const workDir = agent.workingDir || this.daemon.projectDir;
|
|
1768
|
+
const targetPath = resolve(workDir, '.groove', 'recommended-team.json');
|
|
1769
|
+
if (!existsSync(targetPath)) return;
|
|
1770
|
+
|
|
1771
|
+
const teamId = agent.teamId || null;
|
|
1772
|
+
|
|
1773
|
+
// If phase 2 is already pending for this team, GUI already consumed it
|
|
1774
|
+
const pending = this.daemon._pendingPhase2 || [];
|
|
1775
|
+
if (teamId && pending.some(g => g.agents.some(a => a.teamId === teamId))) return;
|
|
1776
|
+
|
|
1777
|
+
// Broadcast so the GUI knows to fetch — even if its polling interval was cleared
|
|
1778
|
+
this.daemon.broadcast({
|
|
1779
|
+
type: 'recommended-team:ready',
|
|
1780
|
+
teamId,
|
|
1781
|
+
agentId: agent.id,
|
|
1782
|
+
agentName: agent.name,
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// Delayed self-consumption: if the GUI doesn't consume within 5s, daemon does it directly
|
|
1786
|
+
setTimeout(() => {
|
|
1787
|
+
if (!existsSync(targetPath)) return; // GUI consumed it
|
|
1788
|
+
try {
|
|
1789
|
+
const raw = JSON.parse(readFileSync(targetPath, 'utf8'));
|
|
1790
|
+
delete raw._meta;
|
|
1791
|
+
const agentConfigs = Array.isArray(raw) ? raw : (raw.agents || []);
|
|
1792
|
+
if (agentConfigs.length === 0) return;
|
|
1793
|
+
|
|
1794
|
+
const phase1 = agentConfigs.filter(a => !a.phase || a.phase === 1);
|
|
1795
|
+
const phase2 = agentConfigs.filter(a => a.phase === 2);
|
|
1796
|
+
if (phase1.length === 0) return;
|
|
1797
|
+
|
|
1798
|
+
// Check again — GUI may have consumed during the timeout
|
|
1799
|
+
const currentPending = this.daemon._pendingPhase2 || [];
|
|
1800
|
+
if (teamId && currentPending.some(g => g.agents.some(a => a.teamId === teamId))) return;
|
|
1801
|
+
|
|
1802
|
+
const baseDir = agent.workingDir || this.daemon.projectDir;
|
|
1803
|
+
const projectDir = raw.projectDir || null;
|
|
1804
|
+
let projectWorkingDir = baseDir;
|
|
1805
|
+
if (projectDir) {
|
|
1806
|
+
const safeName = String(projectDir).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
|
|
1807
|
+
projectWorkingDir = resolve(baseDir, safeName);
|
|
1808
|
+
mkdirSync(projectWorkingDir, { recursive: true });
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const defaultTeamId = teamId || this.daemon.teams.getDefault()?.id || null;
|
|
1812
|
+
const phase1Ids = [];
|
|
1813
|
+
|
|
1814
|
+
// Spawn phase 1 agents
|
|
1815
|
+
const spawnPromises = phase1.map(async (config) => {
|
|
1816
|
+
try {
|
|
1817
|
+
const validated = validateAgentConfig({
|
|
1818
|
+
role: config.role,
|
|
1819
|
+
scope: config.scope || [],
|
|
1820
|
+
prompt: config.prompt || '',
|
|
1821
|
+
provider: config.provider || agent.provider || this.daemon.config?.defaultProvider,
|
|
1822
|
+
model: config.model || agent.model || this.daemon.config?.defaultModel || 'auto',
|
|
1823
|
+
permission: config.permission || 'auto',
|
|
1824
|
+
workingDir: config.workingDir || projectWorkingDir,
|
|
1825
|
+
name: config.name || undefined,
|
|
1826
|
+
});
|
|
1827
|
+
validated.teamId = defaultTeamId;
|
|
1828
|
+
const spawned = await this.spawn(validated);
|
|
1829
|
+
phase1Ids.push(spawned.id);
|
|
1830
|
+
return spawned;
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
console.error(`[Groove] Autonomous team launch: failed to spawn ${config.role}: ${err.message}`);
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
Promise.all(spawnPromises).then(() => {
|
|
1838
|
+
// Set up phase 2 pending
|
|
1839
|
+
if (phase2.length > 0 && phase1Ids.length > 0) {
|
|
1840
|
+
this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
1841
|
+
this.daemon._pendingPhase2.push({
|
|
1842
|
+
waitFor: phase1Ids,
|
|
1843
|
+
agents: phase2.map(c => ({
|
|
1844
|
+
role: c.role, scope: c.scope || [], prompt: c.prompt || '',
|
|
1845
|
+
provider: c.provider || agent.provider || this.daemon.config?.defaultProvider,
|
|
1846
|
+
model: c.model || agent.model || this.daemon.config?.defaultModel || 'auto',
|
|
1847
|
+
permission: c.permission || 'auto',
|
|
1848
|
+
workingDir: c.workingDir || projectWorkingDir,
|
|
1849
|
+
name: c.name || undefined,
|
|
1850
|
+
teamId: defaultTeamId,
|
|
1851
|
+
})),
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Clean up the file
|
|
1856
|
+
try { unlinkSync(targetPath); } catch { /* */ }
|
|
1857
|
+
this.daemon.audit?.log('team.autonomousLaunch', { teamId: defaultTeamId, phase1: phase1Ids.length, phase2: phase2.length });
|
|
1858
|
+
console.log(`[Groove] Autonomous team launch: ${phase1Ids.length} phase 1 agents spawned for team ${defaultTeamId}`);
|
|
1859
|
+
});
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
console.error(`[Groove] Autonomous team consumption failed: ${err.message}`);
|
|
1862
|
+
}
|
|
1863
|
+
}, 5000);
|
|
1864
|
+
} catch { /* best effort */ }
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1758
1867
|
/**
|
|
1759
1868
|
* Check if a completed/crashed agent was the last phase 1 agent in a team.
|
|
1760
1869
|
* If so, auto-spawn the phase 2 (QC/finisher) agents.
|
|
@@ -1834,13 +1943,26 @@ For normal file edits within your scope, proceed without review.
|
|
|
1834
1943
|
try {
|
|
1835
1944
|
const validated = validateAgentConfig(config);
|
|
1836
1945
|
if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
|
|
1946
|
+
validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
|
|
1947
|
+
const existingId = existing?.id || null;
|
|
1837
1948
|
const p = this.spawn(validated).then((agent) => {
|
|
1949
|
+
registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
|
|
1838
1950
|
this.daemon.broadcast({
|
|
1839
1951
|
type: 'phase2:spawned',
|
|
1840
1952
|
agentId: agent.id,
|
|
1953
|
+
oldAgentId: existingId,
|
|
1841
1954
|
name: agent.name,
|
|
1842
1955
|
role: agent.role,
|
|
1843
1956
|
});
|
|
1957
|
+
if (existingId) {
|
|
1958
|
+
this.daemon.broadcast({
|
|
1959
|
+
type: 'rotation:complete',
|
|
1960
|
+
agentId: agent.id,
|
|
1961
|
+
oldAgentId: existingId,
|
|
1962
|
+
agentName: agent.name,
|
|
1963
|
+
reason: 'phase2_respawn',
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1844
1966
|
this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
|
|
1845
1967
|
}).catch((err) => {
|
|
1846
1968
|
console.error(`[Groove] Phase 2 spawn failed for ${config.role}: ${err.message}`);
|
|
@@ -1884,6 +2006,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1884
2006
|
const teamId = agent.teamId;
|
|
1885
2007
|
if (!teamId) return;
|
|
1886
2008
|
if (this._reviewTriggered.has(teamId)) return;
|
|
2009
|
+
this._reviewTriggered.add(teamId);
|
|
1887
2010
|
|
|
1888
2011
|
const registry = this.daemon.registry;
|
|
1889
2012
|
const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
|
|
@@ -1898,7 +2021,6 @@ For normal file edits within your scope, proceed without review.
|
|
|
1898
2021
|
a.role !== 'planner' && (a.status === 'running' || a.status === 'starting'));
|
|
1899
2022
|
if (hasRunning) return;
|
|
1900
2023
|
|
|
1901
|
-
this._reviewTriggered.add(teamId);
|
|
1902
2024
|
this._reviewPending.add(teamId);
|
|
1903
2025
|
|
|
1904
2026
|
const journalist = this.daemon.journalist;
|
|
@@ -1912,6 +2034,12 @@ For normal file edits within your scope, proceed without review.
|
|
|
1912
2034
|
for (const f of (journalist?.getAgentFiles(a) || [])) allFiles.add(f);
|
|
1913
2035
|
}
|
|
1914
2036
|
|
|
2037
|
+
if (!originalSpec.trim() && !plannerResult.trim() && allFiles.size === 0) {
|
|
2038
|
+
console.log(`[Groove] Review skipped for team ${teamId}: no spec, plan, or files to review`);
|
|
2039
|
+
this._reviewPending.delete(teamId);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1915
2043
|
const reviewPrompt = `You are reviewing a completed team build against the original specification.
|
|
1916
2044
|
|
|
1917
2045
|
## Original Task
|
|
@@ -225,6 +225,14 @@ export class TunnelManager {
|
|
|
225
225
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
226
226
|
|
|
227
227
|
try {
|
|
228
|
+
const probeCmd = [
|
|
229
|
+
`NV=$(node --version 2>/dev/null || echo "");`,
|
|
230
|
+
`echo "__NODE__${`$\{NV\}`}__NODE_END__";`,
|
|
231
|
+
`S=$(curl -sf http://localhost:${REMOTE_PORT}/api/status 2>/dev/null);`,
|
|
232
|
+
`if [ -n "$S" ]; then echo "__GROOVE_RUNNING__$S__GROOVE_END__";`,
|
|
233
|
+
`else which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__; fi`,
|
|
234
|
+
].join(' ');
|
|
235
|
+
|
|
228
236
|
const result = execFileSync('ssh', [
|
|
229
237
|
...keyArgs,
|
|
230
238
|
'-p', String(config.port || 22),
|
|
@@ -232,27 +240,32 @@ export class TunnelManager {
|
|
|
232
240
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
233
241
|
'-o', 'BatchMode=yes',
|
|
234
242
|
target,
|
|
235
|
-
sshCmd(
|
|
243
|
+
sshCmd(probeCmd),
|
|
236
244
|
], {
|
|
237
245
|
encoding: 'utf8',
|
|
238
246
|
timeout: 15000,
|
|
239
247
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
240
248
|
});
|
|
241
249
|
|
|
250
|
+
const nodeMatch = result.match(/__NODE__(.+?)__NODE_END__/);
|
|
251
|
+
const nodeVersionRaw = nodeMatch ? nodeMatch[1].trim() : '';
|
|
252
|
+
const nodeInstalled = nodeVersionRaw.startsWith('v');
|
|
253
|
+
const nodeVersion = nodeInstalled ? nodeVersionRaw : null;
|
|
254
|
+
|
|
242
255
|
if (result.includes('__GROOVE_NOT_INSTALLED__')) {
|
|
243
|
-
return { reachable: true, daemonRunning: false, grooveInstalled: false };
|
|
256
|
+
return { reachable: true, daemonRunning: false, grooveInstalled: false, nodeInstalled, nodeVersion };
|
|
244
257
|
}
|
|
245
258
|
if (result.includes('__GROOVE_STOPPED__')) {
|
|
246
259
|
const verMatch = result.match(/__GROOVE_VER__(.+?)__GROOVE_STOPPED__/);
|
|
247
260
|
const remoteVersion = verMatch ? verMatch[1].trim() : null;
|
|
248
|
-
return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion };
|
|
261
|
+
return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
|
|
249
262
|
}
|
|
250
263
|
const runMatch = result.match(/__GROOVE_RUNNING__(.+?)__GROOVE_END__/);
|
|
251
264
|
let remoteVersion = null;
|
|
252
265
|
if (runMatch) {
|
|
253
266
|
try { remoteVersion = JSON.parse(runMatch[1]).version || null; } catch { /* ignore */ }
|
|
254
267
|
}
|
|
255
|
-
return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion };
|
|
268
|
+
return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
|
|
256
269
|
} catch (err) {
|
|
257
270
|
const stderr = err.stderr?.toString() || '';
|
|
258
271
|
if (stderr.includes('Permission denied')) {
|
|
@@ -271,14 +284,17 @@ export class TunnelManager {
|
|
|
271
284
|
|
|
272
285
|
if (this.active.has(id)) {
|
|
273
286
|
const existing = this.active.get(id);
|
|
274
|
-
return { localPort: existing.localPort, pid: existing.pid };
|
|
287
|
+
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
275
288
|
}
|
|
276
289
|
|
|
277
290
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
|
|
278
291
|
|
|
292
|
+
// For known servers, skip the full test — tunnel first, check version after
|
|
279
293
|
let testResult;
|
|
280
294
|
if (opts.skipTest && opts.testResult) {
|
|
281
295
|
testResult = opts.testResult;
|
|
296
|
+
} else if (config.lastConnected && opts.skipTest !== false) {
|
|
297
|
+
testResult = { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion: null };
|
|
282
298
|
} else {
|
|
283
299
|
testResult = await this.test(id);
|
|
284
300
|
}
|
|
@@ -286,22 +302,19 @@ export class TunnelManager {
|
|
|
286
302
|
throw new Error(testResult.error || 'Host unreachable');
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
// First-time only: install groove if missing, start daemon if not running
|
|
289
306
|
let preConnectHandled = false;
|
|
290
307
|
if (!testResult.daemonRunning && !testResult.grooveInstalled) {
|
|
291
308
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
|
|
292
309
|
await this.remoteInstall(id);
|
|
293
310
|
preConnectHandled = true;
|
|
294
311
|
} else if (!testResult.daemonRunning && testResult.grooveInstalled) {
|
|
295
|
-
const localVer = getLocalVersion();
|
|
296
|
-
if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
|
|
297
|
-
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: testResult.remoteVersion, to: localVer } });
|
|
298
|
-
await this._remoteUpgrade(id, config);
|
|
299
|
-
}
|
|
300
312
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
301
313
|
await this.autoStart(id);
|
|
302
314
|
preConnectHandled = true;
|
|
303
315
|
}
|
|
304
316
|
|
|
317
|
+
// Establish SSH tunnel
|
|
305
318
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
306
319
|
|
|
307
320
|
const localPort = await this._findAvailablePort();
|
|
@@ -357,9 +370,7 @@ export class TunnelManager {
|
|
|
357
370
|
failCount: 0,
|
|
358
371
|
});
|
|
359
372
|
|
|
360
|
-
// Verify
|
|
361
|
-
// The cached test result (line 270) assumes daemonRunning=true based on
|
|
362
|
-
// lastConnected, but the daemon may have stopped since then.
|
|
373
|
+
// Verify daemon is reachable through tunnel, start if needed
|
|
363
374
|
let remoteAlive = false;
|
|
364
375
|
try {
|
|
365
376
|
const probe = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
@@ -371,7 +382,6 @@ export class TunnelManager {
|
|
|
371
382
|
if (!remoteAlive && config.autoStart) {
|
|
372
383
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
373
384
|
await this.autoStart(id);
|
|
374
|
-
// Give the daemon a moment to accept connections through the tunnel
|
|
375
385
|
for (let i = 0; i < 5; i++) {
|
|
376
386
|
await new Promise(r => setTimeout(r, 1000));
|
|
377
387
|
try {
|
|
@@ -385,8 +395,8 @@ export class TunnelManager {
|
|
|
385
395
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'waiting', message: 'Remote daemon not running. Start it manually or enable auto-start.' } });
|
|
386
396
|
}
|
|
387
397
|
|
|
388
|
-
|
|
389
|
-
if (remoteAlive && !preConnectHandled
|
|
398
|
+
// Auto-upgrade: check version through tunnel, upgrade if behind
|
|
399
|
+
if (remoteAlive && !preConnectHandled) {
|
|
390
400
|
await this._checkAndUpgradeRunning(id, config, localPort);
|
|
391
401
|
}
|
|
392
402
|
|
|
@@ -440,97 +450,95 @@ export class TunnelManager {
|
|
|
440
450
|
}
|
|
441
451
|
|
|
442
452
|
async _checkAndUpgradeRunning(id, config, localPort) {
|
|
443
|
-
const localVer = getLocalVersion();
|
|
444
|
-
if (localVer === '0.0.0') return;
|
|
445
|
-
|
|
446
453
|
try {
|
|
454
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'checking' } });
|
|
455
|
+
|
|
456
|
+
// Get remote daemon version
|
|
447
457
|
const resp = await fetch(`http://localhost:${localPort}/api/status`, {
|
|
448
458
|
signal: AbortSignal.timeout(5000),
|
|
449
459
|
});
|
|
450
460
|
if (!resp.ok) return;
|
|
451
461
|
const status = await resp.json();
|
|
452
|
-
const
|
|
453
|
-
if (!
|
|
454
|
-
|
|
455
|
-
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: oldVersion, to: localVer } });
|
|
462
|
+
const remoteVer = status.version;
|
|
463
|
+
if (!remoteVer) return;
|
|
456
464
|
|
|
465
|
+
// Check latest version on npm (from the remote server)
|
|
457
466
|
const target = `${config.user}@${config.host}`;
|
|
458
467
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
459
468
|
const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
|
|
460
|
-
const pinnedPkg = `groove-dev@${localVer}`;
|
|
461
|
-
const installCmd = npmGlobalInstall(pinnedPkg, config.user);
|
|
462
469
|
|
|
470
|
+
let npmVer;
|
|
463
471
|
try {
|
|
464
|
-
execFileSync('ssh', [...sshBase, sshCmd(
|
|
465
|
-
encoding: 'utf8',
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
timeout: 120000,
|
|
474
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
475
|
-
});
|
|
472
|
+
npmVer = execFileSync('ssh', [...sshBase, sshCmd('npm view groove-dev version 2>/dev/null')], {
|
|
473
|
+
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
474
|
+
}).trim();
|
|
475
|
+
} catch { return; }
|
|
476
|
+
|
|
477
|
+
if (!npmVer || npmVer === remoteVer) {
|
|
478
|
+
const localVer = getLocalVersion();
|
|
479
|
+
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: remoteVer, match: remoteVer === localVer } });
|
|
480
|
+
return;
|
|
476
481
|
}
|
|
477
482
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
timeout: 10000,
|
|
481
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
482
|
-
}).trim();
|
|
483
|
-
const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
|
|
483
|
+
// Remote is behind npm — upgrade
|
|
484
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: remoteVer, to: npmVer } });
|
|
484
485
|
|
|
485
|
-
|
|
486
|
-
|
|
486
|
+
const installCmd = npmGlobalInstall(`groove-dev@${npmVer}`, config.user);
|
|
487
|
+
const cleanupCmd = 'rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true';
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
|
|
491
|
+
encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
495
|
+
if (errOutput.includes('ENOTEMPTY')) {
|
|
496
|
+
execFileSync('ssh', [...sshBase, sshCmd(cleanupCmd)], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
497
|
+
execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
498
|
+
} else {
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
487
501
|
}
|
|
488
502
|
|
|
503
|
+
// Restart remote daemon
|
|
489
504
|
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
|
|
490
505
|
const setProjectDir = config.projectDir
|
|
491
506
|
? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
|
|
492
507
|
: '';
|
|
493
508
|
const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status && (${setProjectDir}true) || true`;
|
|
494
|
-
|
|
495
|
-
encoding: 'utf8',
|
|
496
|
-
timeout: 60000,
|
|
497
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
509
|
+
execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
|
|
510
|
+
encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
498
511
|
});
|
|
499
512
|
|
|
513
|
+
// Verify through tunnel
|
|
500
514
|
let daemonVer = null;
|
|
501
|
-
try { daemonVer = JSON.parse(restartResult.trim()).version || null; } catch { /* parse failed */ }
|
|
502
|
-
|
|
503
515
|
for (let i = 0; i < 3; i++) {
|
|
504
516
|
try {
|
|
505
517
|
const check = await fetch(`http://localhost:${localPort}/api/status`, {
|
|
506
518
|
signal: AbortSignal.timeout(3000),
|
|
507
519
|
});
|
|
508
520
|
if (check.ok) {
|
|
509
|
-
|
|
510
|
-
daemonVer = checkData.version || daemonVer;
|
|
521
|
+
daemonVer = (await check.json()).version || null;
|
|
511
522
|
break;
|
|
512
523
|
}
|
|
513
524
|
} catch { /* retry */ }
|
|
514
525
|
await new Promise(r => setTimeout(r, 2000));
|
|
515
526
|
}
|
|
516
527
|
|
|
528
|
+
const localVer = getLocalVersion();
|
|
517
529
|
if (daemonVer) {
|
|
518
530
|
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: daemonVer, match: daemonVer === localVer } });
|
|
519
531
|
} else {
|
|
520
|
-
this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from:
|
|
532
|
+
this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from: remoteVer, attempted: npmVer } });
|
|
521
533
|
}
|
|
522
534
|
|
|
523
|
-
this.daemon.audit.log('tunnel.upgrade', { id, from:
|
|
535
|
+
this.daemon.audit.log('tunnel.upgrade', { id, from: remoteVer, to: daemonVer || npmVer });
|
|
524
536
|
} catch (err) {
|
|
525
537
|
try {
|
|
526
538
|
const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
527
539
|
if (verify.ok) {
|
|
528
540
|
const verifyData = await verify.json();
|
|
529
|
-
|
|
530
|
-
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
|
|
541
|
+
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: getLocalVersion(), remoteVersion: verifyData.version, match: false } });
|
|
534
542
|
return;
|
|
535
543
|
}
|
|
536
544
|
} catch { /* tunnel verification failed */ }
|