groove-dev 0.27.32 → 0.27.34
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/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/start.js +12 -9
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +28 -1
- package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +16 -4
- package/node_modules/@groove-dev/daemon/src/memory.js +6 -1
- package/node_modules/@groove-dev/daemon/src/process.js +44 -28
- package/node_modules/@groove-dev/daemon/src/rotator.js +1 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +65 -15
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
- package/node_modules/@groove-dev/gui/src/components/layout/project-picker.jsx +127 -0
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +22 -15
- package/node_modules/@groove-dev/gui/src/stores/groove.js +39 -2
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/start.js +12 -9
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +28 -1
- package/packages/daemon/src/firstrun.js +1 -0
- package/packages/daemon/src/index.js +14 -0
- package/packages/daemon/src/journalist.js +16 -4
- package/packages/daemon/src/memory.js +6 -1
- package/packages/daemon/src/process.js +44 -28
- package/packages/daemon/src/rotator.js +1 -0
- package/packages/daemon/src/tunnel-manager.js +65 -15
- package/packages/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
- package/packages/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/layout/app-shell.jsx +2 -0
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
- package/packages/gui/src/components/layout/project-picker.jsx +127 -0
- package/packages/gui/src/components/settings/quick-connect.jsx +22 -15
- package/packages/gui/src/stores/groove.js +39 -2
|
@@ -14,15 +14,18 @@ export async function start(options) {
|
|
|
14
14
|
// ── First-run interactive wizard ────────────────────────────
|
|
15
15
|
let setupKeys = {};
|
|
16
16
|
if (isFirstRun) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
if (!process.stdin.isTTY) {
|
|
18
|
+
console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
|
|
19
|
+
} else {
|
|
20
|
+
try {
|
|
21
|
+
const result = await runSetupWizard();
|
|
22
|
+
setupKeys = result.keys || {};
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.code === 'ERR_USE_AFTER_CLOSE') {
|
|
25
|
+
console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
|
|
26
|
+
} else {
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
}
|
|
@@ -695,6 +695,28 @@ export function createApi(app, daemon) {
|
|
|
695
695
|
});
|
|
696
696
|
});
|
|
697
697
|
|
|
698
|
+
// --- Project Directory ---
|
|
699
|
+
|
|
700
|
+
app.get('/api/project-dir', (req, res) => {
|
|
701
|
+
res.json({
|
|
702
|
+
projectDir: daemon.projectDir,
|
|
703
|
+
recentProjects: daemon.config.recentProjects || [],
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
app.post('/api/project-dir', (req, res) => {
|
|
708
|
+
const { path: dirPath } = req.body || {};
|
|
709
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
710
|
+
return res.status(400).json({ error: 'path is required' });
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
daemon.setProjectDir(dirPath);
|
|
714
|
+
res.json({ projectDir: daemon.projectDir, recentProjects: daemon.config.recentProjects || [] });
|
|
715
|
+
} catch (err) {
|
|
716
|
+
res.status(400).json({ error: err.message });
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
698
720
|
// --- Teams (live agent groups) ---
|
|
699
721
|
|
|
700
722
|
app.get('/api/teams', (req, res) => {
|
|
@@ -3330,7 +3352,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3330
3352
|
|
|
3331
3353
|
app.post('/api/tunnels/:id/connect', proOnly, async (req, res) => {
|
|
3332
3354
|
try {
|
|
3333
|
-
const
|
|
3355
|
+
const opts = {};
|
|
3356
|
+
if (req.body?.skipTest && req.body?.testResult) {
|
|
3357
|
+
opts.skipTest = true;
|
|
3358
|
+
opts.testResult = req.body.testResult;
|
|
3359
|
+
}
|
|
3360
|
+
const result = await daemon.tunnelManager.connect(req.params.id, opts);
|
|
3334
3361
|
res.json(result);
|
|
3335
3362
|
} catch (err) {
|
|
3336
3363
|
const body = { error: err.message };
|
|
@@ -337,6 +337,20 @@ export class Daemon {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
setProjectDir(dirPath) {
|
|
341
|
+
if (!dirPath || typeof dirPath !== 'string') throw new Error('Invalid path');
|
|
342
|
+
const resolved = resolve(dirPath);
|
|
343
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
344
|
+
throw new Error('Directory does not exist');
|
|
345
|
+
}
|
|
346
|
+
this.projectDir = resolved;
|
|
347
|
+
const recents = (this.config.recentProjects || []).filter((r) => r.path !== resolved);
|
|
348
|
+
recents.unshift({ path: resolved, name: resolved.split('/').pop(), openedAt: new Date().toISOString() });
|
|
349
|
+
this.config.recentProjects = recents.slice(0, 10);
|
|
350
|
+
saveConfig(this.grooveDir, this.config);
|
|
351
|
+
this.broadcast({ type: 'project-dir:changed', data: { projectDir: resolved } });
|
|
352
|
+
}
|
|
353
|
+
|
|
340
354
|
async _pollSubscription() {
|
|
341
355
|
if (!this.authToken) return;
|
|
342
356
|
const API_BASE = 'https://docs.groovedev.ai/api/v1';
|
|
@@ -22,6 +22,7 @@ export class Journalist {
|
|
|
22
22
|
this.history = []; // recent synthesis summaries
|
|
23
23
|
this._debounceTimer = null;
|
|
24
24
|
this._debounceReason = null;
|
|
25
|
+
this._forceNextCycle = false;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
start(intervalMs = DEFAULT_INTERVAL) {
|
|
@@ -61,6 +62,9 @@ export class Journalist {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
requestSynthesis(reason = 'unknown') {
|
|
65
|
+
if (reason === 'completion' || reason === 'rotation_complete') {
|
|
66
|
+
this._forceNextCycle = true;
|
|
67
|
+
}
|
|
64
68
|
if (this._debounceTimer) {
|
|
65
69
|
this._debounceReason = reason;
|
|
66
70
|
return;
|
|
@@ -84,7 +88,10 @@ export class Journalist {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
async cycle() {
|
|
87
|
-
if (this.synthesizing)
|
|
91
|
+
if (this.synthesizing) {
|
|
92
|
+
console.log(' Journalist: skipping cycle (already synthesizing)');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
88
95
|
|
|
89
96
|
const agents = this.daemon.registry.getAll();
|
|
90
97
|
const running = agents.filter((a) => a.status === 'running');
|
|
@@ -99,14 +106,19 @@ export class Journalist {
|
|
|
99
106
|
const activeAgents = [...running, ...recentlyCompleted];
|
|
100
107
|
|
|
101
108
|
// Skip if no agents to synthesize
|
|
102
|
-
if (activeAgents.length === 0)
|
|
109
|
+
if (activeAgents.length === 0) {
|
|
110
|
+
console.log(' Journalist: skipping cycle (no active agents)');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
103
113
|
|
|
104
|
-
// Smart scheduling: skip if no new log output since last cycle
|
|
105
|
-
if (this.lastCycleAt && !this.hasNewActivity(activeAgents)) {
|
|
114
|
+
// Smart scheduling: skip if no new log output since last cycle (unless forced by completion/rotation)
|
|
115
|
+
if (this.lastCycleAt && !this._forceNextCycle && !this.hasNewActivity(activeAgents)) {
|
|
116
|
+
console.log(' Journalist: skipping cycle (no new activity)');
|
|
106
117
|
return;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
this.synthesizing = true;
|
|
121
|
+
this._forceNextCycle = false;
|
|
110
122
|
this.cycleCount++;
|
|
111
123
|
this.lastCycleAt = Date.now();
|
|
112
124
|
|
|
@@ -165,12 +165,13 @@ export class MemoryStore {
|
|
|
165
165
|
const entries = [];
|
|
166
166
|
const blocks = content.split(/\n(?=## Rotation )/);
|
|
167
167
|
for (const block of blocks) {
|
|
168
|
-
const headerMatch = block.match(/^## Rotation (\d+)
|
|
168
|
+
const headerMatch = block.match(/^## Rotation (\d+) — [^(]*\(([\w?-]+) →/);
|
|
169
169
|
if (!headerMatch) continue;
|
|
170
170
|
const body = block.replace(/\n---\s*$/, '').trim();
|
|
171
171
|
entries.push({
|
|
172
172
|
rotationN: parseInt(headerMatch[1], 10),
|
|
173
173
|
body,
|
|
174
|
+
agentId: headerMatch[2] || null,
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
return entries;
|
|
@@ -182,6 +183,10 @@ export class MemoryStore {
|
|
|
182
183
|
appendHandoffBrief(role, entry, workingDir, teamId) {
|
|
183
184
|
if (!role || !entry) return false;
|
|
184
185
|
const chain = this.getHandoffChain(role, workingDir, teamId);
|
|
186
|
+
|
|
187
|
+
// Dedup: prevent the same agent from having multiple entries in the chain
|
|
188
|
+
if (entry.agentId && chain.some(c => c.agentId === entry.agentId)) return false;
|
|
189
|
+
|
|
185
190
|
const nextN = (chain[0]?.rotationN || 0) + 1;
|
|
186
191
|
|
|
187
192
|
const block = [
|
|
@@ -296,6 +296,7 @@ export class ProcessManager {
|
|
|
296
296
|
this.peakContextUsage = new Map(); // agentId -> highest contextUsage seen
|
|
297
297
|
this.pendingMessages = new Map(); // agentId -> { message, timestamp }
|
|
298
298
|
this._streamThrottle = new Map(); // agentId -> { timer, pending }
|
|
299
|
+
this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
|
|
299
300
|
|
|
300
301
|
}
|
|
301
302
|
|
|
@@ -623,7 +624,11 @@ For normal file edits within your scope, proceed without review.
|
|
|
623
624
|
const files = this.daemon.journalist?.getAgentFiles(agent) || [];
|
|
624
625
|
if (files.length > 0) this._triggerIdleQC(agent);
|
|
625
626
|
this._processHandoffs(agent);
|
|
626
|
-
this.
|
|
627
|
+
if (this._rotatingAgents.has(agent.id)) {
|
|
628
|
+
this._rotatingAgents.delete(agent.id);
|
|
629
|
+
} else {
|
|
630
|
+
this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
|
|
631
|
+
}
|
|
627
632
|
}
|
|
628
633
|
});
|
|
629
634
|
|
|
@@ -815,7 +820,11 @@ For normal file edits within your scope, proceed without review.
|
|
|
815
820
|
const files = this.daemon.journalist?.getAgentFiles(agent) || [];
|
|
816
821
|
if (files.length > 0) this._triggerIdleQC(agent);
|
|
817
822
|
this._processHandoffs(agent);
|
|
818
|
-
this.
|
|
823
|
+
if (this._rotatingAgents.has(agent.id)) {
|
|
824
|
+
this._rotatingAgents.delete(agent.id);
|
|
825
|
+
} else {
|
|
826
|
+
this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
|
|
827
|
+
}
|
|
819
828
|
}
|
|
820
829
|
|
|
821
830
|
// Update Layer 7 specialization profile for this agent's session
|
|
@@ -1149,34 +1158,41 @@ For normal file edits within your scope, proceed without review.
|
|
|
1149
1158
|
});
|
|
1150
1159
|
}
|
|
1151
1160
|
|
|
1152
|
-
_writeCompletionHandoff(agent) {
|
|
1161
|
+
async _writeCompletionHandoff(agent) {
|
|
1153
1162
|
if (!this.daemon.memory || !this.daemon.journalist) return;
|
|
1154
1163
|
try {
|
|
1155
1164
|
const agentData = this.daemon.registry.get(agent.id);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1165
|
+
|
|
1166
|
+
let brief;
|
|
1167
|
+
try {
|
|
1168
|
+
brief = await this.daemon.journalist.generateHandoffBrief(agent, { reason: 'completed' });
|
|
1169
|
+
} catch {
|
|
1170
|
+
// Fallback to structural brief if AI synthesis fails
|
|
1171
|
+
const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
|
|
1172
|
+
const agentLog = filteredLogs[agent.id];
|
|
1173
|
+
const entries = agentLog?.entries || [];
|
|
1174
|
+
const files = this.daemon.journalist.getAgentFiles(agent) || [];
|
|
1175
|
+
|
|
1176
|
+
const toolSummary = entries
|
|
1177
|
+
.filter(e => e.type === 'tool')
|
|
1178
|
+
.map(e => `- ${e.tool}: ${e.input}`)
|
|
1179
|
+
.slice(-15)
|
|
1180
|
+
.join('\n');
|
|
1181
|
+
|
|
1182
|
+
const errorSummary = entries
|
|
1183
|
+
.filter(e => e.type === 'error')
|
|
1184
|
+
.map(e => `- ${e.text}`)
|
|
1185
|
+
.slice(-5)
|
|
1186
|
+
.join('\n');
|
|
1187
|
+
|
|
1188
|
+
brief = [
|
|
1189
|
+
`Agent ${agent.name} (${agent.role}) completed.`,
|
|
1190
|
+
agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
|
|
1191
|
+
files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
|
|
1192
|
+
toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
|
|
1193
|
+
errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
|
|
1194
|
+
].filter(Boolean).join('\n');
|
|
1195
|
+
}
|
|
1180
1196
|
|
|
1181
1197
|
this.daemon.memory.appendHandoffBrief(agent.role, {
|
|
1182
1198
|
timestamp: new Date().toISOString(),
|
|
@@ -1184,7 +1200,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1184
1200
|
reason: 'completed',
|
|
1185
1201
|
oldTokens: agentData?.tokensUsed || 0,
|
|
1186
1202
|
contextUsage: agentData?.contextUsage || 0,
|
|
1187
|
-
brief: brief.slice(0, 4000),
|
|
1203
|
+
brief: (typeof brief === 'string' ? brief : '').slice(0, 4000),
|
|
1188
1204
|
}, agent.workingDir, agent.teamId);
|
|
1189
1205
|
} catch { /* best-effort */ }
|
|
1190
1206
|
}
|
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
import { execFileSync, spawn } from 'child_process';
|
|
5
5
|
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
|
6
|
-
import { resolve } from 'path';
|
|
6
|
+
import { resolve, dirname, join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
7
8
|
import { createConnection } from 'net';
|
|
8
9
|
import crypto from 'crypto';
|
|
9
10
|
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
function getLocalVersion() {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
|
|
15
|
+
return pkg.version || '0.0.0';
|
|
16
|
+
} catch { return '0.0.0'; }
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
const REMOTE_PORT = 31415;
|
|
11
20
|
const DEFAULT_LOCAL_PORT = 31416;
|
|
12
21
|
const MAX_PORT_ATTEMPTS = 10;
|
|
@@ -185,7 +194,7 @@ export class TunnelManager {
|
|
|
185
194
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
186
195
|
'-o', 'BatchMode=yes',
|
|
187
196
|
target,
|
|
188
|
-
`curl -sf http://localhost:${REMOTE_PORT}/api/health 2>/dev/null || (which groove >/dev/null 2>&1 && echo __GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__)`,
|
|
197
|
+
`bash -lc 'curl -sf http://localhost:${REMOTE_PORT}/api/health 2>/dev/null || (which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__)'`,
|
|
189
198
|
], {
|
|
190
199
|
encoding: 'utf8',
|
|
191
200
|
timeout: 20000,
|
|
@@ -196,7 +205,9 @@ export class TunnelManager {
|
|
|
196
205
|
return { reachable: true, daemonRunning: false, grooveInstalled: false };
|
|
197
206
|
}
|
|
198
207
|
if (result.includes('__GROOVE_STOPPED__')) {
|
|
199
|
-
|
|
208
|
+
const verMatch = result.match(/__GROOVE_VER__(.+?)__GROOVE_STOPPED__/);
|
|
209
|
+
const remoteVersion = verMatch ? verMatch[1].trim() : null;
|
|
210
|
+
return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion };
|
|
200
211
|
}
|
|
201
212
|
return { reachable: true, daemonRunning: true, grooveInstalled: true };
|
|
202
213
|
} catch (err) {
|
|
@@ -211,7 +222,7 @@ export class TunnelManager {
|
|
|
211
222
|
}
|
|
212
223
|
}
|
|
213
224
|
|
|
214
|
-
async connect(id) {
|
|
225
|
+
async connect(id, opts = {}) {
|
|
215
226
|
const config = this.saved.get(id);
|
|
216
227
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
217
228
|
|
|
@@ -220,7 +231,14 @@ export class TunnelManager {
|
|
|
220
231
|
return { localPort: existing.localPort, pid: existing.pid };
|
|
221
232
|
}
|
|
222
233
|
|
|
223
|
-
|
|
234
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
|
|
235
|
+
|
|
236
|
+
let testResult;
|
|
237
|
+
if (opts.skipTest && opts.testResult) {
|
|
238
|
+
testResult = opts.testResult;
|
|
239
|
+
} else {
|
|
240
|
+
testResult = await this.test(id);
|
|
241
|
+
}
|
|
224
242
|
if (!testResult.reachable) {
|
|
225
243
|
throw new Error(testResult.error || 'Host unreachable');
|
|
226
244
|
}
|
|
@@ -229,10 +247,17 @@ export class TunnelManager {
|
|
|
229
247
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
|
|
230
248
|
await this.remoteInstall(id);
|
|
231
249
|
} else if (!testResult.daemonRunning && testResult.grooveInstalled) {
|
|
250
|
+
const localVer = getLocalVersion();
|
|
251
|
+
if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
|
|
252
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: testResult.remoteVersion, to: localVer } });
|
|
253
|
+
await this._remoteUpgrade(id, config);
|
|
254
|
+
}
|
|
232
255
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
233
256
|
await this.autoStart(id);
|
|
234
257
|
}
|
|
235
258
|
|
|
259
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
260
|
+
|
|
236
261
|
const localPort = await this._findAvailablePort();
|
|
237
262
|
const target = `${config.user}@${config.host}`;
|
|
238
263
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
@@ -257,13 +282,16 @@ export class TunnelManager {
|
|
|
257
282
|
let stderrBuf = '';
|
|
258
283
|
tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
259
284
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
285
|
+
let tunnelUp = false;
|
|
286
|
+
for (let elapsed = 0; elapsed < 8000; elapsed += 500) {
|
|
287
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
288
|
+
if (tunnel.exitCode !== null) {
|
|
289
|
+
throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
|
|
290
|
+
}
|
|
291
|
+
tunnelUp = await this._isPortInUse(localPort);
|
|
292
|
+
if (tunnelUp) break;
|
|
264
293
|
}
|
|
265
294
|
|
|
266
|
-
const tunnelUp = await this._isPortInUse(localPort);
|
|
267
295
|
if (!tunnelUp) {
|
|
268
296
|
try { process.kill(tunnel.pid); } catch { /* ignore */ }
|
|
269
297
|
throw new Error('Tunnel started but port forward not active');
|
|
@@ -320,6 +348,24 @@ export class TunnelManager {
|
|
|
320
348
|
}
|
|
321
349
|
}
|
|
322
350
|
|
|
351
|
+
async _remoteUpgrade(id, config) {
|
|
352
|
+
const target = `${config.user}@${config.host}`;
|
|
353
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
354
|
+
const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
|
|
355
|
+
const installCmd = config.user === 'root' ? 'npm i -g groove-dev' : 'sudo npm i -g groove-dev';
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
|
|
359
|
+
encoding: 'utf8',
|
|
360
|
+
timeout: 120000,
|
|
361
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
365
|
+
throw new Error(`Remote upgrade failed: ${output.slice(-400)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
323
369
|
async autoStart(id) {
|
|
324
370
|
const config = this.saved.get(id);
|
|
325
371
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
@@ -334,7 +380,7 @@ export class TunnelManager {
|
|
|
334
380
|
'-o', 'ConnectTimeout=10',
|
|
335
381
|
'-o', 'BatchMode=yes',
|
|
336
382
|
target,
|
|
337
|
-
`bash -lc 'nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep
|
|
383
|
+
`bash -lc 'nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)'`,
|
|
338
384
|
], {
|
|
339
385
|
encoding: 'utf8',
|
|
340
386
|
timeout: 45000,
|
|
@@ -342,10 +388,12 @@ export class TunnelManager {
|
|
|
342
388
|
});
|
|
343
389
|
|
|
344
390
|
if (result.includes('__DAEMON_FAIL__')) {
|
|
345
|
-
|
|
391
|
+
const logLines = result.split('__DAEMON_FAIL__')[1]?.trim() || '';
|
|
392
|
+
const detail = logLines ? `: ${logLines.slice(-300)}` : '';
|
|
393
|
+
throw new Error(`Remote daemon failed to start${detail}`);
|
|
346
394
|
}
|
|
347
395
|
} catch (err) {
|
|
348
|
-
if (err.message.includes('
|
|
396
|
+
if (err.message.includes('Remote daemon failed')) throw err;
|
|
349
397
|
const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
350
398
|
throw new Error(`Failed to start remote daemon: ${output.slice(-300)}`);
|
|
351
399
|
}
|
|
@@ -411,7 +459,7 @@ export class TunnelManager {
|
|
|
411
459
|
try {
|
|
412
460
|
const result = execFileSync('ssh', [
|
|
413
461
|
...sshBase,
|
|
414
|
-
remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep
|
|
462
|
+
remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
|
|
415
463
|
], {
|
|
416
464
|
encoding: 'utf8',
|
|
417
465
|
timeout: 45000,
|
|
@@ -419,7 +467,9 @@ export class TunnelManager {
|
|
|
419
467
|
});
|
|
420
468
|
|
|
421
469
|
if (result.includes('__DAEMON_FAIL__')) {
|
|
422
|
-
|
|
470
|
+
const logLines = result.split('__DAEMON_FAIL__')[1]?.trim() || '';
|
|
471
|
+
const detail = logLines ? `: ${logLines.slice(-300)}` : '';
|
|
472
|
+
throw new Error(`Groove installed but daemon failed to start${detail}`);
|
|
423
473
|
}
|
|
424
474
|
} catch (err) {
|
|
425
475
|
if (err.message.includes('Groove installed')) throw err;
|