groove-dev 0.27.142 → 0.27.144
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/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
- package/node_modules/@groove-dev/daemon/src/index.js +3 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
- package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
- package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
- package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
- package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
- package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
- package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
- package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
- package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
- package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
- package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
- package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
- package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
- package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
- package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
- package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
- package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
- package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
- package/node_modules/@groove-dev/gui/src/app.css +35 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
- package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
- package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
- package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
- package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
- package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
- package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
- package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
- package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
- package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
- package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
- package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
- package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
- package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
- package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
- package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
- package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
- package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
- package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
- package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
- package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
- package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
- package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +1086 -6532
- package/packages/daemon/src/gateways/manager.js +35 -1
- package/packages/daemon/src/index.js +3 -0
- package/packages/daemon/src/journalist.js +23 -13
- package/packages/daemon/src/mlx-server.js +365 -0
- package/packages/daemon/src/model-lab.js +308 -12
- package/packages/daemon/src/pm.js +1 -1
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/local.js +36 -8
- package/packages/daemon/src/registry.js +21 -5
- package/packages/daemon/src/routes/agents.js +889 -0
- package/packages/daemon/src/routes/coordination.js +318 -0
- package/packages/daemon/src/routes/files.js +751 -0
- package/packages/daemon/src/routes/integrations.js +485 -0
- package/packages/daemon/src/routes/network.js +1784 -0
- package/packages/daemon/src/routes/providers.js +755 -0
- package/packages/daemon/src/routes/schedules.js +110 -0
- package/packages/daemon/src/routes/teams.js +650 -0
- package/packages/daemon/src/scheduler.js +456 -24
- package/packages/daemon/src/teams.js +1 -1
- package/packages/daemon/src/validate.js +38 -1
- package/packages/daemon/templates/mlx-setup.json +12 -0
- package/packages/daemon/templates/tgi-setup.json +1 -1
- package/packages/daemon/templates/vllm-setup.json +1 -1
- package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
- package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
- package/packages/gui/src/app.css +35 -0
- package/packages/gui/src/components/agents/agent-config.jsx +1 -128
- package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
- package/packages/gui/src/components/agents/agent-node.jsx +8 -13
- package/packages/gui/src/components/agents/code-review.jsx +159 -122
- package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
- package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
- package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
- package/packages/gui/src/components/automations/automation-card.jsx +274 -0
- package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
- package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
- package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
- package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
- package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
- package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
- package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
- package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
- package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
- package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
- package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
- package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
- package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
- package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
- package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
- package/packages/gui/src/components/network/network-health.jsx +2 -2
- package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
- package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
- package/packages/gui/src/components/ui/sheet.jsx +5 -2
- package/packages/gui/src/lib/cron.js +64 -0
- package/packages/gui/src/lib/status.js +24 -24
- package/packages/gui/src/lib/theme-hex.js +1 -0
- package/packages/gui/src/stores/groove.js +34 -3144
- package/packages/gui/src/stores/helpers.js +10 -0
- package/packages/gui/src/stores/slices/agents-slice.js +452 -0
- package/packages/gui/src/stores/slices/automations-slice.js +96 -0
- package/packages/gui/src/stores/slices/chat-slice.js +227 -0
- package/packages/gui/src/stores/slices/editor-slice.js +285 -0
- package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
- package/packages/gui/src/stores/slices/network-slice.js +361 -0
- package/packages/gui/src/stores/slices/preview-slice.js +109 -0
- package/packages/gui/src/stores/slices/providers-slice.js +897 -0
- package/packages/gui/src/stores/slices/teams-slice.js +413 -0
- package/packages/gui/src/stores/slices/ui-slice.js +98 -0
- package/packages/gui/src/views/agents.jsx +5 -5
- package/packages/gui/src/views/dashboard.jsx +12 -13
- package/packages/gui/src/views/marketplace.jsx +191 -3
- package/packages/gui/src/views/model-lab.jsx +17 -6
- package/packages/gui/src/views/models.jsx +410 -509
- package/packages/gui/src/views/network.jsx +3 -3
- package/packages/gui/src/views/settings.jsx +81 -94
- package/packages/gui/src/views/teams.jsx +40 -483
- package/SECURITY_SWEEP.md +0 -228
- package/TRAINING_DATA_v4.md +0 -6
- package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
- package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
- package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
- package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
- package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
- package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
- package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
- package/packages/gui/src/views/preview.jsx +0 -6
- package/packages/gui/src/views/subscription-panel.jsx +0 -327
- package/test.py +0 -571
|
@@ -0,0 +1,1784 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { resolve, join, dirname, sep } from 'path';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'fs';
|
|
4
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
5
|
+
import { createHash, randomUUID } from 'crypto';
|
|
6
|
+
import { hostname, homedir } from 'os';
|
|
7
|
+
import { StringDecoder } from 'string_decoder';
|
|
8
|
+
import { OllamaProvider } from '../providers/ollama.js';
|
|
9
|
+
import { supportsSignalFlag, compareSemver, parseSemver } from '../providers/groove-network.js';
|
|
10
|
+
|
|
11
|
+
export function registerNetworkRoutes(app, daemon) {
|
|
12
|
+
|
|
13
|
+
// --- Federation ---
|
|
14
|
+
|
|
15
|
+
// Federation status (v1 — includes whitelist, connections, ambassadors)
|
|
16
|
+
app.get('/api/federation', (req, res) => {
|
|
17
|
+
res.json(daemon.federation.getStatus());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.get('/api/federation/test', async (req, res) => {
|
|
21
|
+
const target = req.query.target;
|
|
22
|
+
if (!target) return res.status(400).json({ error: 'target required' });
|
|
23
|
+
let host;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(`http://${target}`);
|
|
26
|
+
host = parsed.hostname.replace(/^\[|]$/g, '');
|
|
27
|
+
} catch {
|
|
28
|
+
return res.status(400).json({ error: 'Invalid target' });
|
|
29
|
+
}
|
|
30
|
+
const privatePatterns = [
|
|
31
|
+
/^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
|
|
32
|
+
/^0\./, /^169\.254\./, /^localhost$/i, /^::1$/,
|
|
33
|
+
/^0\.0\.0\.0$/, /^fc/i, /^fd/i, /^fe80/i,
|
|
34
|
+
];
|
|
35
|
+
if (privatePatterns.some(p => p.test(host))) {
|
|
36
|
+
return res.status(400).json({ error: 'Private/local addresses are not allowed' });
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
41
|
+
const resp = await fetch(`http://${target}/api/health`, { signal: controller.signal });
|
|
42
|
+
clearTimeout(timeout);
|
|
43
|
+
if (resp.ok) {
|
|
44
|
+
const data = await resp.json();
|
|
45
|
+
return res.json({ reachable: true, version: data.version, peerId: data.daemonId, agents: data.agents });
|
|
46
|
+
}
|
|
47
|
+
res.json({ reachable: false });
|
|
48
|
+
} catch {
|
|
49
|
+
res.json({ reachable: false });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// List peers
|
|
54
|
+
app.get('/api/federation/peers', (req, res) => {
|
|
55
|
+
res.json(daemon.federation.getPeers());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Unpair a peer
|
|
59
|
+
app.delete('/api/federation/peers/:id', (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
daemon.federation.unpair(req.params.id);
|
|
62
|
+
res.json({ ok: true });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
res.status(400).json({ error: err.message });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Initiate pairing with a remote daemon
|
|
69
|
+
app.post('/api/federation/initiate', async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const { remoteUrl } = req.body;
|
|
72
|
+
if (!remoteUrl || typeof remoteUrl !== 'string') {
|
|
73
|
+
return res.status(400).json({ error: 'remoteUrl is required (string)' });
|
|
74
|
+
}
|
|
75
|
+
const result = await daemon.federation.initiatePairing(remoteUrl);
|
|
76
|
+
res.json(result);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
res.status(400).json({ error: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- Federation v1: Whitelist ---
|
|
83
|
+
|
|
84
|
+
app.get('/api/federation/whitelist', (req, res) => {
|
|
85
|
+
res.json(daemon.federation.whitelist?.list() || []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
app.post('/api/federation/whitelist', (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const { ip, port, name } = req.body;
|
|
91
|
+
if (!ip || typeof ip !== 'string') {
|
|
92
|
+
return res.status(400).json({ error: 'ip is required (string)' });
|
|
93
|
+
}
|
|
94
|
+
const entry = daemon.federation.whitelist.add(ip, port, name);
|
|
95
|
+
daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
|
|
96
|
+
res.json(entry);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
res.status(400).json({ error: err.message });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.delete('/api/federation/whitelist/:ip', (req, res) => {
|
|
103
|
+
try {
|
|
104
|
+
daemon.federation.whitelist.remove(req.params.ip);
|
|
105
|
+
daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
|
|
106
|
+
res.json({ ok: true });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
res.status(400).json({ error: err.message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Probe endpoint — remote daemons hit this to check if they are whitelisted
|
|
113
|
+
app.get('/api/federation/whitelist-check', (req, res) => {
|
|
114
|
+
const ip = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
115
|
+
const whitelisted = daemon.federation.isWhitelisted(ip);
|
|
116
|
+
res.json({
|
|
117
|
+
whitelisted,
|
|
118
|
+
...(whitelisted ? { daemonId: daemon.federation._daemonId() } : {}),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- Federation v1: Knock ---
|
|
123
|
+
|
|
124
|
+
app.post('/api/federation/knock', (req, res) => {
|
|
125
|
+
try {
|
|
126
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
127
|
+
const { senderId, publicKey, payload, signature } = req.body;
|
|
128
|
+
if (!senderId || !publicKey || !payload || !signature) {
|
|
129
|
+
return res.status(400).json({ error: 'senderId, publicKey, payload, and signature are required' });
|
|
130
|
+
}
|
|
131
|
+
const result = daemon.federation.handleKnock(senderId, publicKey, payload, signature, callerIp);
|
|
132
|
+
res.json(result);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.status(403).json({ error: err.message });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// --- Federation v1: Connections ---
|
|
139
|
+
|
|
140
|
+
app.get('/api/federation/connections', (req, res) => {
|
|
141
|
+
res.json(daemon.federation.connections?.getStatus() || []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- Federation v1: Diplomatic Pouch ---
|
|
145
|
+
|
|
146
|
+
app.post('/api/federation/pouch', (req, res) => {
|
|
147
|
+
try {
|
|
148
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
149
|
+
if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
|
|
150
|
+
return res.status(403).json({ error: 'Caller IP not whitelisted' });
|
|
151
|
+
}
|
|
152
|
+
const { senderId, payload, signature } = req.body;
|
|
153
|
+
if (!senderId || !payload || !signature) {
|
|
154
|
+
return res.status(400).json({ error: 'senderId, payload, and signature are required' });
|
|
155
|
+
}
|
|
156
|
+
const result = daemon.federation.ambassadors.receivePouch(senderId, payload, signature);
|
|
157
|
+
res.json(result);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
res.status(403).json({ error: err.message });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
app.get('/api/federation/pouch/log', (req, res) => {
|
|
164
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
|
|
165
|
+
res.json(daemon.federation.ambassadors?.getPouchLog(limit) || []);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Send a pouch message to a peer (local agents/GUI call this)
|
|
169
|
+
app.post('/api/federation/pouch/send', async (req, res) => {
|
|
170
|
+
try {
|
|
171
|
+
const { peerId, contract } = req.body;
|
|
172
|
+
if (!peerId || !contract) {
|
|
173
|
+
return res.status(400).json({ error: 'peerId and contract are required' });
|
|
174
|
+
}
|
|
175
|
+
const result = await daemon.federation.ambassadors.sendPouch(peerId, contract);
|
|
176
|
+
res.json(result);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
res.status(400).json({ error: err.message });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Accept incoming pairing request from a remote daemon
|
|
183
|
+
app.post('/api/federation/pair', (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
186
|
+
const { id, name, port, publicKey } = req.body;
|
|
187
|
+
if (!id || !publicKey) {
|
|
188
|
+
return res.status(400).json({ error: 'id and publicKey are required' });
|
|
189
|
+
}
|
|
190
|
+
const result = daemon.federation.acceptPairing({ id, name, port, publicKey }, callerIp);
|
|
191
|
+
res.json(result);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
res.status(403).json({ error: err.message });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Legacy contract endpoints (kept for backward compat)
|
|
198
|
+
app.post('/api/federation/contract', (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
201
|
+
if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
|
|
202
|
+
return res.status(403).json({ error: 'Caller IP not whitelisted' });
|
|
203
|
+
}
|
|
204
|
+
const { senderId, payload, signature } = req.body;
|
|
205
|
+
if (!senderId || !payload || !signature) {
|
|
206
|
+
return res.status(400).json({ error: 'senderId, payload, and signature are required' });
|
|
207
|
+
}
|
|
208
|
+
const result = daemon.federation.receiveContract(senderId, payload, signature);
|
|
209
|
+
res.json(result);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
res.status(403).json({ error: err.message });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
app.post('/api/federation/contract/send', async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const { peerId, contract } = req.body;
|
|
218
|
+
if (!peerId || !contract) {
|
|
219
|
+
return res.status(400).json({ error: 'peerId and contract are required' });
|
|
220
|
+
}
|
|
221
|
+
const result = await daemon.federation.sendContract(peerId, contract);
|
|
222
|
+
res.json(result);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
res.status(400).json({ error: err.message });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- Tunnels (Remote Access) ---
|
|
229
|
+
|
|
230
|
+
app.get('/api/tunnels', (req, res) => {
|
|
231
|
+
res.json(daemon.tunnelManager.getSaved());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
app.post('/api/tunnels', (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
const { name, host, user, port, sshKeyPath, autoStart, autoConnect, projectDir } = req.body;
|
|
237
|
+
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name is required (string)' });
|
|
238
|
+
if (!host || typeof host !== 'string') return res.status(400).json({ error: 'host is required (string)' });
|
|
239
|
+
const result = daemon.tunnelManager.save({ name, host, user, port, sshKeyPath, autoStart, autoConnect, projectDir });
|
|
240
|
+
res.json(result);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
res.status(400).json({ error: err.message });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
app.patch('/api/tunnels/:id', (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
const result = daemon.tunnelManager.update(req.params.id, req.body);
|
|
249
|
+
res.json(result);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
res.status(400).json({ error: err.message });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
app.delete('/api/tunnels/:id', async (req, res) => {
|
|
256
|
+
try {
|
|
257
|
+
await daemon.tunnelManager.delete(req.params.id);
|
|
258
|
+
res.json({ ok: true });
|
|
259
|
+
} catch (err) {
|
|
260
|
+
res.status(400).json({ error: err.message });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.post('/api/tunnels/:id/test', async (req, res) => {
|
|
265
|
+
try {
|
|
266
|
+
const result = await daemon.tunnelManager.test(req.params.id);
|
|
267
|
+
res.json(result);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
res.status(400).json({ error: err.message });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.post('/api/tunnels/:id/connect', async (req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
const opts = {};
|
|
276
|
+
if (req.body?.skipTest && req.body?.testResult) {
|
|
277
|
+
opts.skipTest = true;
|
|
278
|
+
opts.testResult = req.body.testResult;
|
|
279
|
+
}
|
|
280
|
+
const result = await daemon.tunnelManager.connect(req.params.id, opts);
|
|
281
|
+
res.json(result);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const body = { error: err.message };
|
|
284
|
+
if (err.testResult) body.testResult = err.testResult;
|
|
285
|
+
res.status(400).json(body);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
app.post('/api/tunnels/:id/disconnect', async (req, res) => {
|
|
290
|
+
try {
|
|
291
|
+
await daemon.tunnelManager.disconnect(req.params.id);
|
|
292
|
+
res.json({ ok: true });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
res.status(400).json({ error: err.message });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
app.post('/api/tunnels/:id/install', async (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const result = await daemon.tunnelManager.remoteInstall(req.params.id);
|
|
301
|
+
res.json(result);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
res.status(400).json({ error: err.message });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
app.post('/api/tunnels/:id/start', async (req, res) => {
|
|
308
|
+
try {
|
|
309
|
+
await daemon.tunnelManager.autoStart(req.params.id);
|
|
310
|
+
res.json({ ok: true });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
res.status(400).json({ error: err.message });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.post('/api/tunnels/:id/upgrade', async (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
const result = await daemon.tunnelManager.forceUpgrade(req.params.id);
|
|
319
|
+
res.json(result);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
res.status(500).json({ error: err.message });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
app.get('/api/tunnels/:id/status', (req, res) => {
|
|
326
|
+
const s = daemon.tunnelManager.getStatus(req.params.id);
|
|
327
|
+
if (!s) return res.status(404).json({ error: 'Remote not found' });
|
|
328
|
+
res.json(s);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// --- Groove Network (Beta) ---
|
|
332
|
+
|
|
333
|
+
// Offline fallback allowlist — SHA-256 hashes of valid codes so plaintext
|
|
334
|
+
// codes aren't exposed in source. Used only when groovedev.ai is unreachable.
|
|
335
|
+
const BETA_CODES_FALLBACK_HASHES = new Set([
|
|
336
|
+
'2dd41c615fd155f322e8381fed28f346ed6592e2bbab1c068f156fa225c02110',
|
|
337
|
+
'034d771385b608bb85d8f0225c561fe3c084b8ce7851221b01f9c2226dfe3e7b',
|
|
338
|
+
'fad2c7b09f9161db518d8c9a8d338831eb3894ef0f36e2c7cb1884cffbb05768',
|
|
339
|
+
'0ff4c9c1d224e59ac370d6f4bf315ae2ec750af014758c8206f38980cb7603ba',
|
|
340
|
+
'08b2ffe7f40afe2894db335860d67af877fa31201b3e2c25736480eb3f7c58ef',
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
function hashCode(code) {
|
|
344
|
+
return createHash('sha256').update(code).digest('hex');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const BETA_VALIDATE_URL = 'https://groovedev.ai/api/beta/validate';
|
|
348
|
+
|
|
349
|
+
const betaAttempts = [];
|
|
350
|
+
const BETA_RATE_LIMIT = 5;
|
|
351
|
+
const BETA_RATE_WINDOW_MS = 60_000;
|
|
352
|
+
|
|
353
|
+
function getMachineId() {
|
|
354
|
+
const idFile = join(daemon.grooveDir, '.machine-id');
|
|
355
|
+
try {
|
|
356
|
+
const existing = readFileSync(idFile, 'utf8').trim();
|
|
357
|
+
if (existing.length >= 32) return existing;
|
|
358
|
+
} catch {}
|
|
359
|
+
const id = createHash('sha256').update(`${hostname()}|${randomUUID()}`).digest('hex');
|
|
360
|
+
try { writeFileSync(idFile, id, { mode: 0o600 }); } catch {}
|
|
361
|
+
return id;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function validateCodeWithServer(code) {
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
367
|
+
try {
|
|
368
|
+
const response = await fetch(BETA_VALIDATE_URL, {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
headers: { 'Content-Type': 'application/json' },
|
|
371
|
+
body: JSON.stringify({ code, machineId: getMachineId() }),
|
|
372
|
+
signal: controller.signal,
|
|
373
|
+
});
|
|
374
|
+
if (!response.ok && response.status !== 200) {
|
|
375
|
+
return { ok: false, reason: 'http', status: response.status };
|
|
376
|
+
}
|
|
377
|
+
const body = await response.json();
|
|
378
|
+
return { ok: true, result: body };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return { ok: false, reason: 'network', error: err.message };
|
|
381
|
+
} finally {
|
|
382
|
+
clearTimeout(timeout);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isNetworkUnlocked() {
|
|
387
|
+
return !!(daemon.config?.networkBeta?.unlocked);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function networkGate(req, res, next) {
|
|
391
|
+
// Return 404 (not 403) so the feature is invisible until unlocked.
|
|
392
|
+
if (!isNetworkUnlocked()) return res.status(404).json({ error: 'Not found' });
|
|
393
|
+
next();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function persistConfig() {
|
|
397
|
+
const { saveConfig } = await import('../firstrun.js');
|
|
398
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
app.get('/api/beta/status', (req, res) => {
|
|
402
|
+
res.json({ unlocked: isNetworkUnlocked() });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
app.post('/api/beta/activate', async (req, res) => {
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
while (betaAttempts.length && betaAttempts[0] < now - BETA_RATE_WINDOW_MS) betaAttempts.shift();
|
|
408
|
+
if (betaAttempts.length >= BETA_RATE_LIMIT) {
|
|
409
|
+
return res.status(429).json({ error: 'Too many attempts. Try again in a minute.' });
|
|
410
|
+
}
|
|
411
|
+
betaAttempts.push(now);
|
|
412
|
+
|
|
413
|
+
const { code } = req.body || {};
|
|
414
|
+
if (typeof code !== 'string' || code.length > 64 || !/^[A-Z0-9-]+$/.test(code)) {
|
|
415
|
+
return res.status(400).json({ error: 'Invalid code format' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const remote = await validateCodeWithServer(code);
|
|
419
|
+
|
|
420
|
+
let valid = false;
|
|
421
|
+
let message = 'Invalid invite code';
|
|
422
|
+
let expiresAt = null;
|
|
423
|
+
let features = [];
|
|
424
|
+
let source = 'server';
|
|
425
|
+
|
|
426
|
+
if (remote.ok && remote.result && typeof remote.result === 'object') {
|
|
427
|
+
valid = remote.result.valid === true;
|
|
428
|
+
if (typeof remote.result.message === 'string') message = remote.result.message;
|
|
429
|
+
if (typeof remote.result.expiresAt === 'string' || remote.result.expiresAt === null) {
|
|
430
|
+
expiresAt = remote.result.expiresAt || null;
|
|
431
|
+
}
|
|
432
|
+
if (Array.isArray(remote.result.features)) features = remote.result.features;
|
|
433
|
+
} else {
|
|
434
|
+
// Offline fallback — only trust the hashed list when we can't reach the server
|
|
435
|
+
source = 'fallback';
|
|
436
|
+
if (BETA_CODES_FALLBACK_HASHES.has(hashCode(code))) {
|
|
437
|
+
valid = true;
|
|
438
|
+
message = 'Activated (offline)';
|
|
439
|
+
features = ['network-node', 'network-consumer'];
|
|
440
|
+
} else {
|
|
441
|
+
message = 'Invalid invite code';
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!valid) {
|
|
446
|
+
daemon.audit.log('beta.activate.denied', { codePrefix: code.slice(0, 10), source });
|
|
447
|
+
return res.status(200).json({ unlocked: false, message });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
daemon.config.networkBeta = {
|
|
451
|
+
...(daemon.config.networkBeta || {}),
|
|
452
|
+
unlocked: true,
|
|
453
|
+
code,
|
|
454
|
+
expiresAt,
|
|
455
|
+
features,
|
|
456
|
+
};
|
|
457
|
+
await persistConfig();
|
|
458
|
+
daemon.audit.log('beta.activate', { codePrefix: code.slice(0, 10), source, features });
|
|
459
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
460
|
+
res.json({ unlocked: true, message, expiresAt, features });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Re-validate stored code against groovedev.ai. Called at daemon startup
|
|
464
|
+
// so revoked or expired codes lock the feature automatically. Non-blocking.
|
|
465
|
+
daemon.revalidateBetaCode = async function revalidateBetaCode() {
|
|
466
|
+
const cfg = daemon.config?.networkBeta;
|
|
467
|
+
if (!cfg?.unlocked) return;
|
|
468
|
+
if (!cfg?.code) {
|
|
469
|
+
daemon.config.networkBeta = { ...cfg, unlocked: false, expiresAt: null, features: [] };
|
|
470
|
+
await persistConfig();
|
|
471
|
+
daemon.audit.log('beta.revoked', { reason: 'missing code' });
|
|
472
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const remote = await validateCodeWithServer(cfg.code);
|
|
476
|
+
// If we couldn't reach the server, keep the current unlocked state —
|
|
477
|
+
// network failures must not lock out beta users.
|
|
478
|
+
if (!remote.ok || !remote.result || typeof remote.result !== 'object') return;
|
|
479
|
+
if (remote.result.valid === true) {
|
|
480
|
+
// Refresh features/expiresAt from server in case they changed
|
|
481
|
+
const next = {
|
|
482
|
+
...cfg,
|
|
483
|
+
expiresAt: typeof remote.result.expiresAt === 'string' ? remote.result.expiresAt : null,
|
|
484
|
+
features: Array.isArray(remote.result.features) ? remote.result.features : (cfg.features || []),
|
|
485
|
+
};
|
|
486
|
+
if (JSON.stringify(next) !== JSON.stringify(cfg)) {
|
|
487
|
+
daemon.config.networkBeta = next;
|
|
488
|
+
await persistConfig();
|
|
489
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Server says invalid — revoke
|
|
494
|
+
daemon.config.networkBeta = {
|
|
495
|
+
...cfg,
|
|
496
|
+
unlocked: false,
|
|
497
|
+
code: null,
|
|
498
|
+
expiresAt: null,
|
|
499
|
+
features: [],
|
|
500
|
+
};
|
|
501
|
+
await persistConfig();
|
|
502
|
+
daemon.audit.log('beta.revoked', { reason: remote.result.message || 'server denied' });
|
|
503
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
app.post('/api/beta/deactivate', async (req, res) => {
|
|
507
|
+
// Stop the node if it's running before locking the feature away.
|
|
508
|
+
if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
|
|
509
|
+
safeKill(daemon.networkNode.proc);
|
|
510
|
+
}
|
|
511
|
+
daemon.networkNode = {
|
|
512
|
+
active: false, status: 'stopped', pid: null, proc: null,
|
|
513
|
+
nodeId: null, layers: null, model: null, sessions: 0,
|
|
514
|
+
hardware: null, startedAt: null, events: [],
|
|
515
|
+
};
|
|
516
|
+
daemon.config.networkBeta = {
|
|
517
|
+
...(daemon.config.networkBeta || {}),
|
|
518
|
+
unlocked: false,
|
|
519
|
+
code: null,
|
|
520
|
+
};
|
|
521
|
+
await persistConfig();
|
|
522
|
+
daemon.audit.log('beta.deactivate', {});
|
|
523
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
524
|
+
res.json({ unlocked: false });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Network node lifecycle (gated)
|
|
528
|
+
|
|
529
|
+
let _localHwCache = null;
|
|
530
|
+
function getLocalHardware() {
|
|
531
|
+
if (!_localHwCache) {
|
|
532
|
+
const sys = OllamaProvider.getSystemHardware();
|
|
533
|
+
const vramGb = sys.gpu?.vram || 0;
|
|
534
|
+
const ramGb = sys.totalRamGb || 0;
|
|
535
|
+
const vramMb = vramGb * 1024;
|
|
536
|
+
const ramMb = ramGb * 1024;
|
|
537
|
+
const fmtGb = (gb) => gb > 0 ? `${gb} GB` : null;
|
|
538
|
+
_localHwCache = {
|
|
539
|
+
device: sys.gpu?.type === 'nvidia' ? 'cuda' : sys.gpu?.type === 'apple-silicon' ? 'metal' : 'cpu',
|
|
540
|
+
gpu: sys.gpu?.name || null,
|
|
541
|
+
memory: fmtGb(vramGb) || fmtGb(ramGb),
|
|
542
|
+
vram: fmtGb(vramGb),
|
|
543
|
+
ram: fmtGb(ramGb),
|
|
544
|
+
cpuCores: sys.cores || null,
|
|
545
|
+
ram_mb: ramMb,
|
|
546
|
+
vram_mb: vramMb,
|
|
547
|
+
gpu_model: sys.gpu?.name || null,
|
|
548
|
+
cpu_cores: sys.cores || 0,
|
|
549
|
+
bandwidth_mbps: 0,
|
|
550
|
+
max_context_length: 0,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return _localHwCache;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function snapshotNode() {
|
|
557
|
+
const n = daemon.networkNode || {};
|
|
558
|
+
const hw = n.hardware || getLocalHardware();
|
|
559
|
+
return {
|
|
560
|
+
active: !!n.active,
|
|
561
|
+
status: n.status || 'stopped',
|
|
562
|
+
nodeId: n.nodeId || null,
|
|
563
|
+
layers: n.layers || null,
|
|
564
|
+
model: n.model || null,
|
|
565
|
+
sessions: n.sessions || 0,
|
|
566
|
+
hardware: hw,
|
|
567
|
+
installed: !!(daemon.config?.networkBeta?.installed),
|
|
568
|
+
ram_mb: Number(hw.ram_mb) || 0,
|
|
569
|
+
vram_mb: Number(hw.vram_mb) || 0,
|
|
570
|
+
gpu_model: hw.gpu_model || hw.gpu || '',
|
|
571
|
+
cpu_cores: Number(hw.cpu_cores) || 0,
|
|
572
|
+
bandwidth_mbps: Number(hw.bandwidth_mbps) || 0.0,
|
|
573
|
+
max_context_length: Number(hw.max_context_length) || 0,
|
|
574
|
+
load: Number(hw.load) || 0.0,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function eventLevel(event) {
|
|
579
|
+
if (event === 'error' || event === 'crashed') return 'error';
|
|
580
|
+
if (event === 'exit' || event === 'stopping' || event === 'disconnected') return 'warning';
|
|
581
|
+
if (event === 'connected' || event === 'node registered' || event === 'shard loaded') return 'success';
|
|
582
|
+
if (event === 'serving session' || event === 'session complete' || event === 'session ended') return 'session';
|
|
583
|
+
return 'info';
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function pushNodeEvent(event, details) {
|
|
587
|
+
const d = details || {};
|
|
588
|
+
const message = typeof d.msg === 'string' ? d.msg
|
|
589
|
+
: typeof d.message === 'string' ? d.message
|
|
590
|
+
: typeof d.line === 'string' ? d.line
|
|
591
|
+
: event;
|
|
592
|
+
const entry = {
|
|
593
|
+
timestamp: new Date().toISOString(),
|
|
594
|
+
event,
|
|
595
|
+
level: eventLevel(event),
|
|
596
|
+
message,
|
|
597
|
+
details: details || null,
|
|
598
|
+
};
|
|
599
|
+
daemon.networkNode.events = daemon.networkNode.events || [];
|
|
600
|
+
daemon.networkNode.events.push(entry);
|
|
601
|
+
if (daemon.networkNode.events.length > 200) {
|
|
602
|
+
daemon.networkNode.events = daemon.networkNode.events.slice(-200);
|
|
603
|
+
}
|
|
604
|
+
daemon.broadcast({ type: 'network:node:event', data: entry });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function normalizeHardware(caps) {
|
|
608
|
+
if (!caps || typeof caps !== 'object') return null;
|
|
609
|
+
const formatMb = (mb) => (Number.isFinite(mb) && mb > 0)
|
|
610
|
+
? (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB` : `${mb} MB`)
|
|
611
|
+
: null;
|
|
612
|
+
const vram = formatMb(caps.vram_mb);
|
|
613
|
+
const ram = formatMb(caps.ram_mb);
|
|
614
|
+
return {
|
|
615
|
+
device: caps.device || null,
|
|
616
|
+
gpu: caps.gpu_model || null,
|
|
617
|
+
memory: vram || ram || null,
|
|
618
|
+
vram,
|
|
619
|
+
ram,
|
|
620
|
+
cpuCores: caps.cpu_cores || null,
|
|
621
|
+
bandwidthMbps: caps.bandwidth_mbps || null,
|
|
622
|
+
maxContext: caps.max_context_length || null,
|
|
623
|
+
ram_mb: Number(caps.ram_mb) || 0,
|
|
624
|
+
vram_mb: Number(caps.vram_mb) || 0,
|
|
625
|
+
gpu_model: caps.gpu_model || null,
|
|
626
|
+
cpu_cores: Number(caps.cpu_cores) || 0,
|
|
627
|
+
bandwidth_mbps: Number(caps.bandwidth_mbps) || 0,
|
|
628
|
+
max_context_length: Number(caps.max_context_length) || 0,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function broadcastNodeStatus() {
|
|
633
|
+
daemon.broadcast({ type: 'network:node:status', data: snapshotNode() });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
app.get('/api/network/node/status', networkGate, (req, res) => {
|
|
637
|
+
res.json(snapshotNode());
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
app.post('/api/network/node/start', networkGate, (req, res) => {
|
|
641
|
+
if (daemon.networkNode?.active) {
|
|
642
|
+
return res.status(409).json({ error: 'Node already running' });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const cfg = daemon.config.networkBeta || {};
|
|
646
|
+
const signal = stripScheme(cfg.signalUrl);
|
|
647
|
+
if (!isAllowedSignalHost(signal)) {
|
|
648
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
649
|
+
}
|
|
650
|
+
const device = cfg.devicePreference || 'auto';
|
|
651
|
+
const maxContext = Number.isFinite(cfg.maxContext) ? cfg.maxContext : 4096;
|
|
652
|
+
|
|
653
|
+
// Resolve deploy path (handles ~ and defaults to ~/Desktop/groove-deploy)
|
|
654
|
+
let deployPath = cfg.deployPath || null;
|
|
655
|
+
if (!deployPath) {
|
|
656
|
+
deployPath = resolve(homedir(), 'Desktop', 'groove-deploy');
|
|
657
|
+
} else if (deployPath.startsWith('~/')) {
|
|
658
|
+
deployPath = resolve(homedir(), deployPath.slice(2));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (!existsSync(deployPath)) {
|
|
662
|
+
return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
|
|
663
|
+
}
|
|
664
|
+
if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(homedir(), 'Desktop'))) {
|
|
665
|
+
return res.status(400).json({ error: 'Deploy path outside allowed directories' });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const signalFlag = supportsSignalFlag(cfg.version) ? '--signal' : '--relay';
|
|
669
|
+
const model = cfg.model || 'Qwen/Qwen3-4B';
|
|
670
|
+
const args = [
|
|
671
|
+
'-m', 'src.node.server',
|
|
672
|
+
signalFlag, signal,
|
|
673
|
+
'--tls',
|
|
674
|
+
'--device', device,
|
|
675
|
+
'--model', model,
|
|
676
|
+
'--max-context', String(maxContext),
|
|
677
|
+
];
|
|
678
|
+
|
|
679
|
+
let proc;
|
|
680
|
+
try {
|
|
681
|
+
proc = spawn(venvPython(deployPath), args, {
|
|
682
|
+
cwd: deployPath,
|
|
683
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
684
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
return res.status(500).json({ error: `Failed to spawn node: ${err.message}` });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
daemon.networkNode = {
|
|
691
|
+
active: true,
|
|
692
|
+
status: 'starting',
|
|
693
|
+
pid: proc.pid,
|
|
694
|
+
proc,
|
|
695
|
+
nodeId: null,
|
|
696
|
+
layers: null,
|
|
697
|
+
model: null,
|
|
698
|
+
sessions: 0,
|
|
699
|
+
hardware: getLocalHardware(),
|
|
700
|
+
startedAt: Date.now(),
|
|
701
|
+
events: [],
|
|
702
|
+
lastTokenTiming: null,
|
|
703
|
+
};
|
|
704
|
+
if (!daemon.networkBenchmarks) daemon.networkBenchmarks = [];
|
|
705
|
+
|
|
706
|
+
pushNodeEvent('starting', { pid: proc.pid, signal, device });
|
|
707
|
+
broadcastNodeStatus();
|
|
708
|
+
|
|
709
|
+
let stderrBuf = '';
|
|
710
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
711
|
+
proc.stderr.on('data', (chunk) => {
|
|
712
|
+
stderrBuf += stderrDecoder.write(chunk);
|
|
713
|
+
let idx;
|
|
714
|
+
while ((idx = stderrBuf.indexOf('\n')) !== -1) {
|
|
715
|
+
const line = stderrBuf.slice(0, idx).trim();
|
|
716
|
+
stderrBuf = stderrBuf.slice(idx + 1);
|
|
717
|
+
if (!line) continue;
|
|
718
|
+
if (line[0] !== '{') {
|
|
719
|
+
// Python node emits plain-text logs like "Node identity: abc123",
|
|
720
|
+
// "shard loaded: layers 0-12", "registered with signal". Parse those
|
|
721
|
+
// here so the GUI reflects reality even without structured logging.
|
|
722
|
+
let changed = false;
|
|
723
|
+
const idMatch = line.match(/Node identity:\s*([A-Za-z0-9_\-:.]+)/i);
|
|
724
|
+
if (idMatch && idMatch[1] !== daemon.networkNode.nodeId) {
|
|
725
|
+
daemon.networkNode.nodeId = idMatch[1]; changed = true;
|
|
726
|
+
}
|
|
727
|
+
const layerMatch = line.match(/layers?\s*(\d+)\s*[-–to]+\s*(\d+)/i);
|
|
728
|
+
if (layerMatch) {
|
|
729
|
+
const start = parseInt(layerMatch[1], 10);
|
|
730
|
+
const end = parseInt(layerMatch[2], 10);
|
|
731
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
732
|
+
daemon.networkNode.layers = [start, end]; changed = true;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const modelMatch = line.match(/model[:\s]+([A-Za-z0-9_\-./]+\/[A-Za-z0-9_\-.]+)/i);
|
|
736
|
+
if (modelMatch && modelMatch[1] !== daemon.networkNode.model) {
|
|
737
|
+
daemon.networkNode.model = modelMatch[1]; changed = true;
|
|
738
|
+
}
|
|
739
|
+
if (/\bregistered\b/i.test(line) || /\bconnected\b/i.test(line)) {
|
|
740
|
+
if (daemon.networkNode.status !== 'connected') {
|
|
741
|
+
daemon.networkNode.status = 'connected'; changed = true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
pushNodeEvent('log', { line });
|
|
745
|
+
if (changed) broadcastNodeStatus();
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
let entry;
|
|
749
|
+
try { entry = JSON.parse(line); } catch { pushNodeEvent('log', { line }); continue; }
|
|
750
|
+
const msg = entry.msg || entry.event || '';
|
|
751
|
+
let changed = false;
|
|
752
|
+
if (entry.node_id && entry.node_id !== daemon.networkNode.nodeId) {
|
|
753
|
+
daemon.networkNode.nodeId = entry.node_id; changed = true;
|
|
754
|
+
}
|
|
755
|
+
if (msg === 'node registered' || msg === 'connected') {
|
|
756
|
+
daemon.networkNode.status = 'connected'; changed = true;
|
|
757
|
+
}
|
|
758
|
+
if (msg === 'shard loaded' || entry.layer_start !== undefined) {
|
|
759
|
+
if (entry.layer_start !== undefined && entry.layer_end !== undefined) {
|
|
760
|
+
daemon.networkNode.layers = [entry.layer_start, entry.layer_end]; changed = true;
|
|
761
|
+
}
|
|
762
|
+
if (entry.model_name) { daemon.networkNode.model = entry.model_name; changed = true; }
|
|
763
|
+
}
|
|
764
|
+
if (msg === 'serving session') {
|
|
765
|
+
daemon.networkNode.sessions = (daemon.networkNode.sessions || 0) + 1; changed = true;
|
|
766
|
+
}
|
|
767
|
+
if (msg === 'session complete' || msg === 'session ended') {
|
|
768
|
+
daemon.networkNode.sessions = Math.max(0, (daemon.networkNode.sessions || 0) - 1); changed = true;
|
|
769
|
+
}
|
|
770
|
+
if (entry.capabilities || entry.hardware) {
|
|
771
|
+
daemon.networkNode.hardware = normalizeHardware(entry.capabilities || entry.hardware); changed = true;
|
|
772
|
+
}
|
|
773
|
+
if (entry.type === 'token') {
|
|
774
|
+
const timing = {
|
|
775
|
+
token_ms: entry.token_ms, pipeline_ms: entry.pipeline_ms,
|
|
776
|
+
prefill_ms: entry.prefill_ms, logits_deser_ms: entry.logits_deser_ms,
|
|
777
|
+
sample_ms: entry.sample_ms, decode_ms: entry.decode_ms,
|
|
778
|
+
tps: entry.tps, ttft_ms: entry.ttft_ms, is_prefill: entry.is_prefill,
|
|
779
|
+
tokens_generated: entry.tokens_generated,
|
|
780
|
+
stages: Array.isArray(entry.stages) ? entry.stages : [],
|
|
781
|
+
};
|
|
782
|
+
daemon.networkNode.lastTokenTiming = timing;
|
|
783
|
+
daemon.broadcast({ type: 'network:token:timing', data: timing });
|
|
784
|
+
}
|
|
785
|
+
if (entry.type === 'timing') {
|
|
786
|
+
const summary = {
|
|
787
|
+
ttft_ms: entry.ttft_ms, tps: entry.tps,
|
|
788
|
+
tokens_generated: entry.tokens_generated,
|
|
789
|
+
total_network_ms: entry.total_network_ms,
|
|
790
|
+
total_compute_ms: entry.total_compute_ms,
|
|
791
|
+
p2p_sends: entry.p2p_sends, relay_sends: entry.relay_sends,
|
|
792
|
+
stage_0_avg_ms: entry.stage_0_avg_ms, stage_0_count: entry.stage_0_count,
|
|
793
|
+
stage_1_avg_ms: entry.stage_1_avg_ms, stage_1_count: entry.stage_1_count,
|
|
794
|
+
t: Date.now(),
|
|
795
|
+
};
|
|
796
|
+
if (!daemon.networkBenchmarks) daemon.networkBenchmarks = [];
|
|
797
|
+
daemon.networkBenchmarks.push(summary);
|
|
798
|
+
if (daemon.networkBenchmarks.length > 100) daemon.networkBenchmarks.shift();
|
|
799
|
+
daemon.broadcast({ type: 'network:timing:summary', data: summary });
|
|
800
|
+
}
|
|
801
|
+
pushNodeEvent(msg || 'log', entry);
|
|
802
|
+
if (changed) broadcastNodeStatus();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
let stdoutBuf = '';
|
|
807
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
808
|
+
proc.stdout.on('data', (chunk) => {
|
|
809
|
+
stdoutBuf += stdoutDecoder.write(chunk);
|
|
810
|
+
let idx;
|
|
811
|
+
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
812
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
813
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
814
|
+
if (line) pushNodeEvent('stdout', { line });
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
proc.on('error', (err) => {
|
|
819
|
+
daemon.networkNode.status = 'error';
|
|
820
|
+
pushNodeEvent('error', { message: err.message });
|
|
821
|
+
broadcastNodeStatus();
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
proc.on('exit', (code, signal) => {
|
|
825
|
+
const trailing = stdoutDecoder.end();
|
|
826
|
+
if (trailing) stdoutBuf += trailing;
|
|
827
|
+
if (stdoutBuf.trim()) pushNodeEvent('stdout', { line: stdoutBuf.trim() });
|
|
828
|
+
const trailingErr = stderrDecoder.end();
|
|
829
|
+
if (trailingErr) stderrBuf += trailingErr;
|
|
830
|
+
daemon.networkNode.active = false;
|
|
831
|
+
daemon.networkNode.status = 'stopped';
|
|
832
|
+
daemon.networkNode.pid = null;
|
|
833
|
+
daemon.networkNode.proc = null;
|
|
834
|
+
pushNodeEvent('exit', { code, signal });
|
|
835
|
+
broadcastNodeStatus();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
daemon.audit.log('network.node.start', { pid: proc.pid, signal, device });
|
|
839
|
+
res.status(202).json({ started: true, ...snapshotNode() });
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
app.post('/api/network/node/stop', networkGate, (req, res) => {
|
|
843
|
+
const node = daemon.networkNode;
|
|
844
|
+
if (!node?.active || !node.proc) {
|
|
845
|
+
return res.status(409).json({ error: 'Node not running' });
|
|
846
|
+
}
|
|
847
|
+
safeKill(node.proc);
|
|
848
|
+
daemon.networkNode.status = 'stopping';
|
|
849
|
+
pushNodeEvent('stopping', { pid: node.pid });
|
|
850
|
+
broadcastNodeStatus();
|
|
851
|
+
daemon.audit.log('network.node.stop', { pid: node.pid });
|
|
852
|
+
res.json({ stopping: true });
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
app.get('/api/network/benchmarks', networkGate, (req, res) => {
|
|
856
|
+
res.json(daemon.networkBenchmarks || []);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
app.get('/api/network/timing', networkGate, (req, res) => {
|
|
860
|
+
res.json({
|
|
861
|
+
current: daemon.networkNode?.lastTokenTiming || null,
|
|
862
|
+
benchmarkCount: (daemon.networkBenchmarks || []).length,
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
app.get('/api/network/traces', networkGate, (req, res) => {
|
|
867
|
+
const tracesDir = resolve(homedir(), '.groove', 'traces');
|
|
868
|
+
if (!existsSync(tracesDir)) return res.json([]);
|
|
869
|
+
try {
|
|
870
|
+
const files = readdirSync(tracesDir)
|
|
871
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
872
|
+
.map((f) => {
|
|
873
|
+
const st = statSync(resolve(tracesDir, f));
|
|
874
|
+
return { filename: f, size: st.size, mtime: st.mtimeMs };
|
|
875
|
+
})
|
|
876
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
877
|
+
res.json(files);
|
|
878
|
+
} catch { res.json([]); }
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
app.get('/api/network/traces/live', networkGate, (req, res) => {
|
|
882
|
+
const tracesDir = resolve(homedir(), '.groove', 'traces');
|
|
883
|
+
if (!existsSync(tracesDir)) {
|
|
884
|
+
return res.json({ lines: [], nextOffset: 0, filename: null, active: false });
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
const files = readdirSync(tracesDir)
|
|
888
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
889
|
+
.map((f) => {
|
|
890
|
+
const st = statSync(resolve(tracesDir, f));
|
|
891
|
+
return { filename: f, mtime: st.mtimeMs };
|
|
892
|
+
})
|
|
893
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
894
|
+
if (files.length === 0) {
|
|
895
|
+
return res.json({ lines: [], nextOffset: 0, filename: null, active: false });
|
|
896
|
+
}
|
|
897
|
+
const newest = files[0];
|
|
898
|
+
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
|
899
|
+
const filePath = resolve(tracesDir, newest.filename);
|
|
900
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
901
|
+
const allLines = raw.split('\n').filter(Boolean);
|
|
902
|
+
const sliced = allLines.slice(offset);
|
|
903
|
+
const parsed = [];
|
|
904
|
+
for (const line of sliced) {
|
|
905
|
+
try { parsed.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
906
|
+
}
|
|
907
|
+
const active = !!(daemon.networkNode?.active && (daemon.networkNode.sessions || 0) > 0);
|
|
908
|
+
res.json({
|
|
909
|
+
lines: parsed,
|
|
910
|
+
nextOffset: offset + sliced.length,
|
|
911
|
+
filename: newest.filename,
|
|
912
|
+
active,
|
|
913
|
+
});
|
|
914
|
+
} catch {
|
|
915
|
+
res.json({ lines: [], nextOffset: 0, filename: null, active: false });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
app.get('/api/network/traces/:filename', networkGate, (req, res) => {
|
|
920
|
+
const { filename } = req.params;
|
|
921
|
+
if (!filename || /[/\\]/.test(filename) || !filename.endsWith('.jsonl')) {
|
|
922
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
923
|
+
}
|
|
924
|
+
const tracesDir = resolve(homedir(), '.groove', 'traces');
|
|
925
|
+
const filePath = resolve(tracesDir, filename);
|
|
926
|
+
if (!filePath.startsWith(tracesDir + sep)) {
|
|
927
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
928
|
+
}
|
|
929
|
+
if (!existsSync(filePath)) {
|
|
930
|
+
return res.status(404).json({ error: 'Trace file not found' });
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
934
|
+
const lines = raw.split('\n').filter(Boolean).slice(0, 5000);
|
|
935
|
+
const entries = [];
|
|
936
|
+
for (const line of lines) {
|
|
937
|
+
try { entries.push(JSON.parse(line)); } catch { /* skip malformed lines */ }
|
|
938
|
+
}
|
|
939
|
+
res.json(entries);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
res.status(500).json({ error: `Failed to read trace: ${err.message}` });
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
function isAllowedSignalHost(host) {
|
|
946
|
+
const h = (host || '').replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
947
|
+
return h === 'signal.groovedev.ai' || h.endsWith('.groovedev.ai');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The Python node/client code prepends the scheme itself from `--tls`.
|
|
951
|
+
// Daemon must pass a BARE host to --relay/--signal; otherwise the Python
|
|
952
|
+
// side ends up with a double-scheme URI like wss://wss://host.
|
|
953
|
+
function stripScheme(url) {
|
|
954
|
+
if (!url) return 'signal.groovedev.ai';
|
|
955
|
+
return url.replace(/^wss?:\/\//i, '').replace(/\/.*$/, '');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
app.get('/api/network/status', networkGate, async (req, res) => {
|
|
959
|
+
const cfg = daemon.config.networkBeta || {};
|
|
960
|
+
const signalHost = cfg.signalUrl || 'signal.groovedev.ai';
|
|
961
|
+
|
|
962
|
+
if (!isAllowedSignalHost(signalHost)) {
|
|
963
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const bareHost = signalHost.replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '');
|
|
967
|
+
const statusUrl = `https://${bareHost}/status`;
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const controller = new AbortController();
|
|
971
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
972
|
+
const r = await fetch(statusUrl, { signal: controller.signal });
|
|
973
|
+
clearTimeout(timer);
|
|
974
|
+
if (r.ok) {
|
|
975
|
+
const data = await r.json();
|
|
976
|
+
// Signal service returns snake_case; GUI expects camelCase.
|
|
977
|
+
const models = Array.isArray(data.models) ? data.models.map((m) => {
|
|
978
|
+
if (!m || typeof m !== 'object') return m;
|
|
979
|
+
const { covered_layers, total_layers, ...rest } = m;
|
|
980
|
+
return {
|
|
981
|
+
...rest,
|
|
982
|
+
...(covered_layers !== undefined ? { coveredLayers: covered_layers } : {}),
|
|
983
|
+
...(total_layers !== undefined ? { totalLayers: total_layers } : {}),
|
|
984
|
+
};
|
|
985
|
+
}) : [];
|
|
986
|
+
const primaryModel = Array.isArray(data.models) && data.models[0] ? data.models[0] : {};
|
|
987
|
+
|
|
988
|
+
// Enrich local node state from signal's authoritative topology.
|
|
989
|
+
// Signal truncates IDs (e.g. "0xf608fd..."), so match by prefix.
|
|
990
|
+
if (daemon.networkNode?.active && daemon.networkNode.nodeId) {
|
|
991
|
+
const selfId = daemon.networkNode.nodeId;
|
|
992
|
+
const signalNodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
993
|
+
const self = signalNodes.find((n) => {
|
|
994
|
+
const nid = n.node_id || n.nodeId || '';
|
|
995
|
+
const prefix = nid.replace(/\.{2,}$/, '');
|
|
996
|
+
return selfId === nid || (prefix.length >= 6 && selfId.startsWith(prefix));
|
|
997
|
+
});
|
|
998
|
+
let changed = false;
|
|
999
|
+
if (self) {
|
|
1000
|
+
if (Array.isArray(self.layers) && self.layers.length === 2) {
|
|
1001
|
+
daemon.networkNode.layers = self.layers;
|
|
1002
|
+
changed = true;
|
|
1003
|
+
}
|
|
1004
|
+
const prev = daemon.networkNode.hardware || getLocalHardware();
|
|
1005
|
+
const enriched = { ...prev };
|
|
1006
|
+
if (self.device) enriched.device = self.device;
|
|
1007
|
+
if (self.gpu_model) { enriched.gpu = self.gpu_model; enriched.gpu_model = self.gpu_model; }
|
|
1008
|
+
if (Number(self.ram_mb) > 0) { enriched.ram_mb = Number(self.ram_mb); }
|
|
1009
|
+
if (Number(self.vram_mb) > 0) { enriched.vram_mb = Number(self.vram_mb); enriched.memory = enriched.vram_mb >= 1024 ? `${(enriched.vram_mb / 1024).toFixed(1)} GB` : `${enriched.vram_mb} MB`; }
|
|
1010
|
+
if (Number(self.cpu_cores) > 0) { enriched.cpu_cores = Number(self.cpu_cores); enriched.cpuCores = Number(self.cpu_cores); }
|
|
1011
|
+
daemon.networkNode.hardware = enriched;
|
|
1012
|
+
changed = true;
|
|
1013
|
+
}
|
|
1014
|
+
const availModel = Array.isArray(data.models)
|
|
1015
|
+
? data.models.find((m) => m && m.available !== false)
|
|
1016
|
+
: null;
|
|
1017
|
+
if (availModel && !daemon.networkNode.model) {
|
|
1018
|
+
daemon.networkNode.model = availModel.name || null;
|
|
1019
|
+
changed = true;
|
|
1020
|
+
}
|
|
1021
|
+
if (changed) broadcastNodeStatus();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const capStr = (s, max = 200) => (typeof s === 'string' ? s.slice(0, max) : s);
|
|
1025
|
+
const selfId = daemon.networkNode?.nodeId;
|
|
1026
|
+
const localHw = getLocalHardware();
|
|
1027
|
+
const safeNodes = (Array.isArray(data.nodes) ? data.nodes : []).map((n) => {
|
|
1028
|
+
const nid = n.node_id || n.nodeId || '';
|
|
1029
|
+
const isSelf = selfId && nid && (nid === selfId || (nid.length >= 6 && selfId.startsWith(nid.replace(/\.{2,}$/, ''))));
|
|
1030
|
+
const base = {
|
|
1031
|
+
node_id: capStr(nid),
|
|
1032
|
+
device: capStr(n.device),
|
|
1033
|
+
layers: Array.isArray(n.layers) ? n.layers.slice(0, 2) : n.layers,
|
|
1034
|
+
status: capStr(n.status, 50),
|
|
1035
|
+
active_sessions: n.active_sessions ?? 0,
|
|
1036
|
+
ram_mb: Number(n.ram_mb) || 0,
|
|
1037
|
+
vram_mb: Number(n.vram_mb) || 0,
|
|
1038
|
+
gpu_model: capStr(n.gpu_model || '', 200),
|
|
1039
|
+
cpu_cores: Number(n.cpu_cores) || 0,
|
|
1040
|
+
bandwidth_mbps: Number(n.bandwidth_mbps) || 0.0,
|
|
1041
|
+
max_context_length: Number(n.max_context_length) || 0,
|
|
1042
|
+
load: Number(n.load) || 0.0,
|
|
1043
|
+
gpu_utilization_pct: Number(n.gpu_utilization_pct) || 0,
|
|
1044
|
+
vram_used_mb: Number(n.vram_used_mb) || 0,
|
|
1045
|
+
ram_used_mb: Number(n.ram_used_mb) || 0,
|
|
1046
|
+
ram_pct: Number(n.ram_pct) || 0,
|
|
1047
|
+
uptime_seconds: Number(n.uptime_seconds) || 0,
|
|
1048
|
+
};
|
|
1049
|
+
if (isSelf) {
|
|
1050
|
+
if (!base.device) base.device = localHw.device;
|
|
1051
|
+
if (!base.gpu_model) base.gpu_model = localHw.gpu_model || '';
|
|
1052
|
+
if (!base.ram_mb) base.ram_mb = localHw.ram_mb;
|
|
1053
|
+
if (!base.vram_mb) base.vram_mb = localHw.vram_mb;
|
|
1054
|
+
if (!base.cpu_cores) base.cpu_cores = localHw.cpu_cores;
|
|
1055
|
+
}
|
|
1056
|
+
return base;
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
return res.json({
|
|
1060
|
+
nodes: safeNodes,
|
|
1061
|
+
models,
|
|
1062
|
+
compute: data.compute || null,
|
|
1063
|
+
coverage: data.covered_layers ?? primaryModel.covered_layers ?? data.coverage ?? 0,
|
|
1064
|
+
totalLayers: data.total_layers ?? primaryModel.total_layers ?? data.totalLayers ?? 36,
|
|
1065
|
+
activeSessions: data.active_sessions ?? data.activeSessions ?? 0,
|
|
1066
|
+
totalNodes: data.total_nodes ?? data.totalNodes ?? (Array.isArray(data.nodes) ? data.nodes.length : 0),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
} catch { /* fall through to local snapshot */ }
|
|
1070
|
+
|
|
1071
|
+
// Fallback: local node snapshot when signal is unreachable.
|
|
1072
|
+
const node = daemon.networkNode || {};
|
|
1073
|
+
const hw = node.hardware || {};
|
|
1074
|
+
const sysHw = OllamaProvider.getSystemHardware();
|
|
1075
|
+
const localRamMb = (sysHw.totalRamGb || 0) * 1024;
|
|
1076
|
+
const localVramMb = (sysHw.gpu?.vram || 0) * 1024;
|
|
1077
|
+
const localCpuCores = sysHw.cores || 0;
|
|
1078
|
+
const selfNode = node.active && node.nodeId ? [{
|
|
1079
|
+
node_id: node.nodeId,
|
|
1080
|
+
device: hw.device || (sysHw.gpu?.type === 'nvidia' ? 'cuda' : sysHw.gpu?.type === 'apple-silicon' ? 'metal' : 'cpu'),
|
|
1081
|
+
layers: node.layers || [0, 0],
|
|
1082
|
+
status: node.status === 'connected' ? 'active' : node.status,
|
|
1083
|
+
active_sessions: node.sessions || 0,
|
|
1084
|
+
ram_mb: localRamMb,
|
|
1085
|
+
vram_mb: localVramMb,
|
|
1086
|
+
gpu_model: sysHw.gpu?.name || '',
|
|
1087
|
+
cpu_cores: localCpuCores,
|
|
1088
|
+
bandwidth_mbps: 0.0,
|
|
1089
|
+
max_context_length: 0,
|
|
1090
|
+
load: 0.0,
|
|
1091
|
+
gpu_utilization_pct: 0,
|
|
1092
|
+
vram_used_mb: 0,
|
|
1093
|
+
ram_used_mb: 0,
|
|
1094
|
+
ram_pct: 0,
|
|
1095
|
+
uptime_seconds: 0,
|
|
1096
|
+
}] : [];
|
|
1097
|
+
const coverage = node.layers ? (node.layers[1] - node.layers[0]) : 0;
|
|
1098
|
+
const localCompute = selfNode.length > 0 ? {
|
|
1099
|
+
total_ram_mb: localRamMb,
|
|
1100
|
+
total_vram_mb: localVramMb,
|
|
1101
|
+
total_cpu_cores: localCpuCores,
|
|
1102
|
+
total_bandwidth_mbps: 0.0,
|
|
1103
|
+
active_nodes: selfNode.length,
|
|
1104
|
+
total_nodes: selfNode.length,
|
|
1105
|
+
avg_load: 0.0,
|
|
1106
|
+
} : null;
|
|
1107
|
+
res.json({
|
|
1108
|
+
nodes: selfNode,
|
|
1109
|
+
models: ['Qwen/Qwen3-4B'],
|
|
1110
|
+
compute: localCompute,
|
|
1111
|
+
coverage,
|
|
1112
|
+
totalLayers: 36,
|
|
1113
|
+
activeSessions: node.sessions || 0,
|
|
1114
|
+
totalNodes: selfNode.length,
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
app.get('/api/network/compute', networkGate, async (req, res) => {
|
|
1119
|
+
const cfg = daemon.config.networkBeta || {};
|
|
1120
|
+
const signalHost = cfg.signalUrl || 'signal.groovedev.ai';
|
|
1121
|
+
|
|
1122
|
+
if (!isAllowedSignalHost(signalHost)) {
|
|
1123
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const bareHost = signalHost.replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '');
|
|
1127
|
+
const statusUrl = `https://${bareHost}/status`;
|
|
1128
|
+
|
|
1129
|
+
try {
|
|
1130
|
+
const controller = new AbortController();
|
|
1131
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
1132
|
+
const r = await fetch(statusUrl, { signal: controller.signal });
|
|
1133
|
+
clearTimeout(timer);
|
|
1134
|
+
if (r.ok) {
|
|
1135
|
+
const data = await r.json();
|
|
1136
|
+
const nodes = (Array.isArray(data.nodes) ? data.nodes : []).map((n) => ({
|
|
1137
|
+
node_id: n.node_id || n.nodeId || '',
|
|
1138
|
+
ram_mb: Number(n.ram_mb) || 0,
|
|
1139
|
+
vram_mb: Number(n.vram_mb) || 0,
|
|
1140
|
+
gpu_model: typeof n.gpu_model === 'string' ? n.gpu_model.slice(0, 200) : '',
|
|
1141
|
+
cpu_cores: Number(n.cpu_cores) || 0,
|
|
1142
|
+
bandwidth_mbps: Number(n.bandwidth_mbps) || 0.0,
|
|
1143
|
+
max_context_length: Number(n.max_context_length) || 0,
|
|
1144
|
+
load: Number(n.load) || 0.0,
|
|
1145
|
+
gpu_utilization_pct: Number(n.gpu_utilization_pct) || 0,
|
|
1146
|
+
vram_used_mb: Number(n.vram_used_mb) || 0,
|
|
1147
|
+
ram_used_mb: Number(n.ram_used_mb) || 0,
|
|
1148
|
+
ram_pct: Number(n.ram_pct) || 0,
|
|
1149
|
+
uptime_seconds: Number(n.uptime_seconds) || 0,
|
|
1150
|
+
}));
|
|
1151
|
+
return res.json({ compute: data.compute || null, nodes });
|
|
1152
|
+
}
|
|
1153
|
+
} catch { /* fall through to local snapshot */ }
|
|
1154
|
+
|
|
1155
|
+
const node = daemon.networkNode || {};
|
|
1156
|
+
const sysHw = OllamaProvider.getSystemHardware();
|
|
1157
|
+
const localRamMb = (sysHw.totalRamGb || 0) * 1024;
|
|
1158
|
+
const localVramMb = (sysHw.gpu?.vram || 0) * 1024;
|
|
1159
|
+
const localCpuCores = sysHw.cores || 0;
|
|
1160
|
+
const isActive = !!(node.active && node.nodeId);
|
|
1161
|
+
const nodes = isActive ? [{
|
|
1162
|
+
node_id: node.nodeId,
|
|
1163
|
+
ram_mb: localRamMb,
|
|
1164
|
+
vram_mb: localVramMb,
|
|
1165
|
+
gpu_model: sysHw.gpu?.name || '',
|
|
1166
|
+
cpu_cores: localCpuCores,
|
|
1167
|
+
bandwidth_mbps: 0.0,
|
|
1168
|
+
max_context_length: 0,
|
|
1169
|
+
load: 0.0,
|
|
1170
|
+
gpu_utilization_pct: 0,
|
|
1171
|
+
vram_used_mb: 0,
|
|
1172
|
+
ram_used_mb: 0,
|
|
1173
|
+
ram_pct: 0,
|
|
1174
|
+
uptime_seconds: 0,
|
|
1175
|
+
}] : [];
|
|
1176
|
+
const compute = isActive ? {
|
|
1177
|
+
total_ram_mb: localRamMb,
|
|
1178
|
+
total_vram_mb: localVramMb,
|
|
1179
|
+
total_cpu_cores: localCpuCores,
|
|
1180
|
+
total_bandwidth_mbps: 0.0,
|
|
1181
|
+
active_nodes: 1,
|
|
1182
|
+
total_nodes: 1,
|
|
1183
|
+
avg_load: 0.0,
|
|
1184
|
+
} : null;
|
|
1185
|
+
res.json({ compute, nodes });
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// --- Network package install/uninstall ---
|
|
1189
|
+
|
|
1190
|
+
const IS_WIN = process.platform === 'win32';
|
|
1191
|
+
const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
|
|
1192
|
+
const NETWORK_VERSION = 'v0.2.0';
|
|
1193
|
+
|
|
1194
|
+
function venvPython(base) {
|
|
1195
|
+
return IS_WIN
|
|
1196
|
+
? join(base, 'venv', 'Scripts', 'python.exe')
|
|
1197
|
+
: join(base, 'venv', 'bin', 'python3');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
let _cachedGitBash = undefined;
|
|
1201
|
+
function findGitBash() {
|
|
1202
|
+
if (_cachedGitBash !== undefined) return _cachedGitBash;
|
|
1203
|
+
try {
|
|
1204
|
+
const gitPath = execFileSync('where', ['git'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
1205
|
+
.toString().trim().split('\n')[0].trim();
|
|
1206
|
+
// git.exe is typically at <Git>\cmd\git.exe — navigate up to Git root
|
|
1207
|
+
const gitDir = dirname(dirname(gitPath));
|
|
1208
|
+
const candidate = join(gitDir, 'bin', 'bash.exe');
|
|
1209
|
+
if (existsSync(candidate)) { _cachedGitBash = candidate; return _cachedGitBash; }
|
|
1210
|
+
} catch { /* where failed — try common paths */ }
|
|
1211
|
+
const fallbacks = [
|
|
1212
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
1213
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
1214
|
+
];
|
|
1215
|
+
for (const p of fallbacks) {
|
|
1216
|
+
if (existsSync(p)) { _cachedGitBash = p; return _cachedGitBash; }
|
|
1217
|
+
}
|
|
1218
|
+
_cachedGitBash = null;
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function spawnSetupSh(cwd) {
|
|
1223
|
+
if (IS_WIN) {
|
|
1224
|
+
const bashPath = findGitBash();
|
|
1225
|
+
if (!bashPath) {
|
|
1226
|
+
const err = new Error('Could not find bash. Ensure Git for Windows is installed from https://git-scm.com');
|
|
1227
|
+
err.code = 'BASH_NOT_FOUND';
|
|
1228
|
+
throw err;
|
|
1229
|
+
}
|
|
1230
|
+
return spawn(bashPath, ['setup.sh', '--json'], {
|
|
1231
|
+
cwd,
|
|
1232
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1233
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
return spawn('bash', ['setup.sh', '--json'], {
|
|
1237
|
+
cwd,
|
|
1238
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1239
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function safeKill(proc, signal = 'SIGINT') {
|
|
1244
|
+
try {
|
|
1245
|
+
if (IS_WIN) { proc.kill(); } else { proc.kill(signal); }
|
|
1246
|
+
} catch { /* ignore */ }
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function networkRoot() {
|
|
1250
|
+
return resolve(homedir(), '.groove', 'network');
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function getInstalledNetworkVersion() {
|
|
1254
|
+
const configured = daemon.config?.networkBeta?.version || null;
|
|
1255
|
+
if (configured) return configured;
|
|
1256
|
+
const installPath = networkRoot();
|
|
1257
|
+
if (!existsSync(resolve(installPath, 'setup.sh'))) return null;
|
|
1258
|
+
try {
|
|
1259
|
+
const { execSync } = require('child_process');
|
|
1260
|
+
const v = execSync('git describe --tags --abbrev=0', {
|
|
1261
|
+
cwd: installPath, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
|
|
1262
|
+
}).toString().trim();
|
|
1263
|
+
return parseSemver(v) ? v : null;
|
|
1264
|
+
} catch {
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
|
|
1270
|
+
// Uses realpathSync when the path exists to defeat symlink escapes.
|
|
1271
|
+
function isInsideGrooveHome(target) {
|
|
1272
|
+
const home = resolve(homedir(), '.groove') + sep;
|
|
1273
|
+
const resolved = resolve(target);
|
|
1274
|
+
let full;
|
|
1275
|
+
try { full = existsSync(resolved) ? realpathSync(resolved) + sep : resolved + sep; }
|
|
1276
|
+
catch { full = resolved + sep; }
|
|
1277
|
+
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + sep : home;
|
|
1278
|
+
return full.startsWith(realHome);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function broadcastInstallProgress(step, message, percent) {
|
|
1282
|
+
daemon.broadcast({
|
|
1283
|
+
type: 'network:install:progress',
|
|
1284
|
+
data: { step, message, percent },
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
app.get('/api/network/install/status', networkGate, (req, res) => {
|
|
1289
|
+
const installPath = networkRoot();
|
|
1290
|
+
const dirExists = existsSync(installPath);
|
|
1291
|
+
const installed = dirExists && existsSync(resolve(installPath, 'setup.sh'));
|
|
1292
|
+
const stale = dirExists && !installed;
|
|
1293
|
+
res.json({
|
|
1294
|
+
installed,
|
|
1295
|
+
stale,
|
|
1296
|
+
path: dirExists ? installPath : null,
|
|
1297
|
+
version: installed ? getInstalledNetworkVersion() : null,
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
app.post('/api/network/install', networkGate, async (req, res) => {
|
|
1302
|
+
if (daemon.networkInstall?.running) {
|
|
1303
|
+
return res.status(409).json({ error: 'Install already in progress' });
|
|
1304
|
+
}
|
|
1305
|
+
if (daemon.config?.networkBeta?.installed) {
|
|
1306
|
+
return res.status(400).json({ error: 'Network package already installed' });
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const installPath = networkRoot();
|
|
1310
|
+
if (!isInsideGrooveHome(installPath)) {
|
|
1311
|
+
return res.status(500).json({ error: 'Invalid install path' });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// If directory exists from a previous failed install, clean it up automatically.
|
|
1315
|
+
if (existsSync(installPath)) {
|
|
1316
|
+
if (daemon.config?.networkBeta?.installed) {
|
|
1317
|
+
return res.status(400).json({ error: 'Install path already exists; uninstall first' });
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
rmSync(installPath, { recursive: true, force: true });
|
|
1321
|
+
daemon.audit?.log?.('network.install.stale-cleanup', { path: installPath });
|
|
1322
|
+
} catch (cleanupErr) {
|
|
1323
|
+
return res.status(500).json({ error: `Failed to clean stale install directory: ${cleanupErr.message}` });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
daemon.networkInstall = { running: true, startedAt: Date.now() };
|
|
1328
|
+
res.status(200).json({ status: 'installing' });
|
|
1329
|
+
|
|
1330
|
+
// Run the install asynchronously; progress flows over WebSocket.
|
|
1331
|
+
(async () => {
|
|
1332
|
+
const cleanup = () => {
|
|
1333
|
+
try {
|
|
1334
|
+
if (existsSync(installPath) && isInsideGrooveHome(installPath)) {
|
|
1335
|
+
rmSync(installPath, { recursive: true, force: true });
|
|
1336
|
+
}
|
|
1337
|
+
} catch { /* ignore */ }
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const fail = (message) => {
|
|
1341
|
+
cleanup();
|
|
1342
|
+
broadcastInstallProgress('error', message, -1);
|
|
1343
|
+
daemon.audit.log('network.install.failed', { message });
|
|
1344
|
+
daemon.networkInstall = { running: false };
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
try {
|
|
1348
|
+
const pat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
|
|
1349
|
+
|
|
1350
|
+
let installVersion;
|
|
1351
|
+
try {
|
|
1352
|
+
installVersion = (await getLatestNetworkTag()) || NETWORK_VERSION;
|
|
1353
|
+
} catch {
|
|
1354
|
+
installVersion = NETWORK_VERSION;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
broadcastInstallProgress('cloning', `Cloning network package ${installVersion}...`, 0);
|
|
1358
|
+
|
|
1359
|
+
// Pre-flight: verify git is installed before attempting clone.
|
|
1360
|
+
const gitInstalled = await new Promise((resolveGit) => {
|
|
1361
|
+
execFile('git', ['--version'], { timeout: 5000 }, (err) => resolveGit(!err));
|
|
1362
|
+
});
|
|
1363
|
+
if (!gitInstalled) {
|
|
1364
|
+
return fail('Git is not installed. Install Git from https://git-scm.com and restart Groove.');
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const cloneArgs = ['clone', '--branch', installVersion, '--depth', '1', NETWORK_REPO_URL, installPath];
|
|
1368
|
+
const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
1369
|
+
if (pat) {
|
|
1370
|
+
cloneEnv.GIT_CONFIG_COUNT = '1';
|
|
1371
|
+
cloneEnv.GIT_CONFIG_KEY_0 = 'http.extraHeader';
|
|
1372
|
+
cloneEnv.GIT_CONFIG_VALUE_0 = `Authorization: token ${pat}`;
|
|
1373
|
+
}
|
|
1374
|
+
const clone = spawn('git', cloneArgs, {
|
|
1375
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1376
|
+
env: cloneEnv,
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
const stripCredentials = (s) => s.replace(/https:\/\/[^@]+@/g, 'https://***@');
|
|
1380
|
+
|
|
1381
|
+
let cloneErr = '';
|
|
1382
|
+
clone.stderr.on('data', (chunk) => {
|
|
1383
|
+
const s = chunk.toString();
|
|
1384
|
+
cloneErr += s;
|
|
1385
|
+
// git writes progress to stderr — relay last line as status.
|
|
1386
|
+
const line = s.split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
1387
|
+
if (line) broadcastInstallProgress('cloning', stripCredentials(line), 5);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
const cloneCode = await new Promise((resolveClone) => {
|
|
1391
|
+
clone.on('error', (err) => resolveClone({ code: -1, err: err.message }));
|
|
1392
|
+
clone.on('close', (code) => resolveClone({ code }));
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
if (cloneCode.code !== 0) {
|
|
1396
|
+
let hint;
|
|
1397
|
+
const errMsg = cloneCode.err || '';
|
|
1398
|
+
const lastLine = cloneErr.trim().split('\n').slice(-1)[0] || '';
|
|
1399
|
+
if (errMsg.includes('ENOENT')) {
|
|
1400
|
+
hint = 'Git is not installed. Install Git from https://git-scm.com and restart Groove.';
|
|
1401
|
+
} else if (/Authentication failed|could not read Username/i.test(cloneErr)) {
|
|
1402
|
+
hint = 'Authentication failed — run "groove set-key github-pat <token>" to set a GitHub PAT.';
|
|
1403
|
+
} else if (/not found/i.test(cloneErr)) {
|
|
1404
|
+
hint = `Repository or tag not found (${installVersion}). Check NETWORK_REPO_URL and tag.`;
|
|
1405
|
+
} else {
|
|
1406
|
+
hint = stripCredentials(lastLine || errMsg || 'git clone failed');
|
|
1407
|
+
}
|
|
1408
|
+
return fail(`Clone failed: ${hint}`);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
broadcastInstallProgress('cloned', 'Repository cloned', 10);
|
|
1412
|
+
|
|
1413
|
+
// Run setup.sh --json from the install directory
|
|
1414
|
+
let setup;
|
|
1415
|
+
try {
|
|
1416
|
+
setup = spawnSetupSh(installPath);
|
|
1417
|
+
} catch (spawnErr) {
|
|
1418
|
+
return fail(`Setup failed: ${spawnErr.message}`);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
daemon.networkInstall.proc = setup;
|
|
1422
|
+
|
|
1423
|
+
let stdoutBuf = '';
|
|
1424
|
+
setup.stdout.on('data', (chunk) => {
|
|
1425
|
+
stdoutBuf += chunk.toString();
|
|
1426
|
+
let idx;
|
|
1427
|
+
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
1428
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
1429
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
1430
|
+
if (!line) continue;
|
|
1431
|
+
if (line[0] !== '{') continue;
|
|
1432
|
+
try {
|
|
1433
|
+
const event = JSON.parse(line);
|
|
1434
|
+
const step = typeof event.step === 'string' ? event.step : 'progress';
|
|
1435
|
+
const message = typeof event.message === 'string' ? event.message : '';
|
|
1436
|
+
const percent = Number.isFinite(event.percent) ? event.percent : null;
|
|
1437
|
+
broadcastInstallProgress(step, message, percent);
|
|
1438
|
+
} catch { /* non-JSON line, ignore */ }
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
let stderrBuf = '';
|
|
1443
|
+
setup.stderr.on('data', (chunk) => {
|
|
1444
|
+
stderrBuf += chunk.toString();
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
const setupResult = await new Promise((resolveSetup) => {
|
|
1448
|
+
setup.on('error', (err) => resolveSetup({ code: -1, err: err.message }));
|
|
1449
|
+
setup.on('close', (code) => resolveSetup({ code }));
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
if (setupResult.code !== 0) {
|
|
1453
|
+
let hint;
|
|
1454
|
+
if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
|
|
1455
|
+
hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
|
|
1456
|
+
} else {
|
|
1457
|
+
hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
1458
|
+
}
|
|
1459
|
+
return fail(`Setup failed: ${hint}`);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
daemon.config.networkBeta = {
|
|
1463
|
+
...(daemon.config.networkBeta || {}),
|
|
1464
|
+
installed: true,
|
|
1465
|
+
deployPath: installPath,
|
|
1466
|
+
version: installVersion,
|
|
1467
|
+
};
|
|
1468
|
+
await persistConfig();
|
|
1469
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
1470
|
+
broadcastInstallProgress('done', `Network package ${installVersion} installed`, 100);
|
|
1471
|
+
daemon.audit.log('network.install', { path: installPath, version: installVersion });
|
|
1472
|
+
daemon.networkInstall = { running: false };
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
fail(err?.message || 'Install failed');
|
|
1475
|
+
}
|
|
1476
|
+
})();
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
app.post('/api/network/uninstall', networkGate, async (req, res) => {
|
|
1480
|
+
if (daemon.networkInstall?.running) {
|
|
1481
|
+
return res.status(409).json({ error: 'Install in progress; wait for it to finish' });
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Stop the running node first (reuse existing stop logic).
|
|
1485
|
+
try {
|
|
1486
|
+
const node = daemon.networkNode;
|
|
1487
|
+
if (node?.active && node.proc && !node.proc.killed) {
|
|
1488
|
+
safeKill(node.proc);
|
|
1489
|
+
daemon.networkNode.status = 'stopping';
|
|
1490
|
+
pushNodeEvent('stopping', { pid: node.pid, reason: 'uninstall' });
|
|
1491
|
+
broadcastNodeStatus();
|
|
1492
|
+
}
|
|
1493
|
+
} catch { /* ignore */ }
|
|
1494
|
+
|
|
1495
|
+
const installPath = networkRoot();
|
|
1496
|
+
if (!isInsideGrooveHome(installPath)) {
|
|
1497
|
+
return res.status(500).json({ error: 'Invalid install path' });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
try {
|
|
1501
|
+
if (existsSync(installPath)) {
|
|
1502
|
+
rmSync(installPath, { recursive: true, force: true });
|
|
1503
|
+
}
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
return res.status(500).json({ error: `Failed to remove install: ${err.message}` });
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
daemon.config.networkBeta = {
|
|
1509
|
+
...(daemon.config.networkBeta || {}),
|
|
1510
|
+
installed: false,
|
|
1511
|
+
deployPath: null,
|
|
1512
|
+
version: null,
|
|
1513
|
+
};
|
|
1514
|
+
await persistConfig();
|
|
1515
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
1516
|
+
daemon.audit.log('network.uninstall', { path: installPath });
|
|
1517
|
+
res.json({ status: 'uninstalled' });
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
// --- Network package update check / update ---
|
|
1521
|
+
|
|
1522
|
+
// 5-minute cache of the latest-tag lookup so startup + GUI polls don't
|
|
1523
|
+
// hammer GitHub. Shape: { latest, fetchedAt }. null until first check.
|
|
1524
|
+
let networkUpdateCache = null;
|
|
1525
|
+
const NETWORK_UPDATE_CACHE_MS = 5 * 60 * 1000;
|
|
1526
|
+
|
|
1527
|
+
// Run `git ls-remote --tags <repo>` and return the highest semver tag.
|
|
1528
|
+
// Resolves to null on git errors / network failure; caller decides how to
|
|
1529
|
+
// surface that. Uses spawn with array args — no shell interpolation.
|
|
1530
|
+
function fetchLatestNetworkTag() {
|
|
1531
|
+
return new Promise((resolvePromise) => {
|
|
1532
|
+
const tagEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
1533
|
+
const tagPat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
|
|
1534
|
+
if (tagPat) {
|
|
1535
|
+
tagEnv.GIT_CONFIG_COUNT = '1';
|
|
1536
|
+
tagEnv.GIT_CONFIG_KEY_0 = 'http.extraHeader';
|
|
1537
|
+
tagEnv.GIT_CONFIG_VALUE_0 = `Authorization: token ${tagPat}`;
|
|
1538
|
+
}
|
|
1539
|
+
const proc = spawn('git', ['ls-remote', '--tags', NETWORK_REPO_URL], {
|
|
1540
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1541
|
+
env: tagEnv,
|
|
1542
|
+
});
|
|
1543
|
+
daemon._networkCheckProc = proc;
|
|
1544
|
+
let stdout = '';
|
|
1545
|
+
let stderr = '';
|
|
1546
|
+
proc.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
1547
|
+
proc.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
1548
|
+
const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
|
|
1549
|
+
proc.on('error', () => { clearTimeout(timeout); daemon._networkCheckProc = null; resolvePromise(null); });
|
|
1550
|
+
proc.on('close', (code) => {
|
|
1551
|
+
daemon._networkCheckProc = null;
|
|
1552
|
+
clearTimeout(timeout);
|
|
1553
|
+
if (code !== 0) return resolvePromise(null);
|
|
1554
|
+
const tags = [];
|
|
1555
|
+
for (const line of stdout.split('\n')) {
|
|
1556
|
+
// Format: <sha>\trefs/tags/v0.1.0 (or .../v0.1.0^{} for annotated)
|
|
1557
|
+
const m = line.match(/refs\/tags\/(v?\d+\.\d+\.\d+[^\s^]*)(?:\^\{\})?$/);
|
|
1558
|
+
if (m && parseSemver(m[1])) tags.push(m[1]);
|
|
1559
|
+
}
|
|
1560
|
+
if (tags.length === 0) return resolvePromise(null);
|
|
1561
|
+
tags.sort(compareSemver);
|
|
1562
|
+
resolvePromise(tags[tags.length - 1]);
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function getLatestNetworkTag(force = false) {
|
|
1568
|
+
if (!force && networkUpdateCache && (Date.now() - networkUpdateCache.fetchedAt) < NETWORK_UPDATE_CACHE_MS) {
|
|
1569
|
+
return networkUpdateCache.latest;
|
|
1570
|
+
}
|
|
1571
|
+
const latest = await fetchLatestNetworkTag();
|
|
1572
|
+
if (latest) networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
1573
|
+
return latest;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
app.get('/api/network/update/check', networkGate, async (req, res) => {
|
|
1577
|
+
const installed = getInstalledNetworkVersion();
|
|
1578
|
+
const force = req.query.force === '1' || req.query.force === 'true';
|
|
1579
|
+
const latest = await getLatestNetworkTag(force);
|
|
1580
|
+
if (!latest) {
|
|
1581
|
+
return res.status(502).json({
|
|
1582
|
+
installed,
|
|
1583
|
+
latest: null,
|
|
1584
|
+
updateAvailable: false,
|
|
1585
|
+
error: 'Could not reach github.com to check for updates',
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
1589
|
+
res.json({ installed, latest, updateAvailable });
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
function broadcastUpdateProgress(step, message, percent) {
|
|
1593
|
+
daemon.broadcast({
|
|
1594
|
+
type: 'network:update:progress',
|
|
1595
|
+
data: { step, message, percent },
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
app.post('/api/network/update', networkGate, async (req, res) => {
|
|
1600
|
+
if (daemon.networkInstall?.running) {
|
|
1601
|
+
return res.status(409).json({ error: 'Install/update already in progress' });
|
|
1602
|
+
}
|
|
1603
|
+
const installPath = networkRoot();
|
|
1604
|
+
const hasInstall = daemon.config?.networkBeta?.installed || existsSync(resolve(installPath, 'setup.sh'));
|
|
1605
|
+
if (!hasInstall) {
|
|
1606
|
+
return res.status(400).json({ error: 'Network package not installed' });
|
|
1607
|
+
}
|
|
1608
|
+
if (!existsSync(installPath) || !isInsideGrooveHome(installPath)) {
|
|
1609
|
+
return res.status(400).json({ error: 'Install path missing or invalid' });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const latest = await getLatestNetworkTag(true);
|
|
1613
|
+
if (!latest) {
|
|
1614
|
+
return res.status(502).json({ error: 'Could not reach github.com to check for updates' });
|
|
1615
|
+
}
|
|
1616
|
+
const current = getInstalledNetworkVersion();
|
|
1617
|
+
if (current && compareSemver(latest, current) <= 0) {
|
|
1618
|
+
return res.status(400).json({ error: 'Already at latest version', installed: current, latest });
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
daemon.networkInstall = { running: true, startedAt: Date.now(), kind: 'update' };
|
|
1622
|
+
res.status(200).json({ status: 'updating', from: current, to: latest });
|
|
1623
|
+
|
|
1624
|
+
(async () => {
|
|
1625
|
+
const fail = (message) => {
|
|
1626
|
+
broadcastUpdateProgress('error', message, -1);
|
|
1627
|
+
daemon.audit.log('network.update.failed', { message, from: current, to: latest });
|
|
1628
|
+
daemon.networkInstall = { running: false };
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
// Stop the running node first so we don't update files under its feet.
|
|
1633
|
+
try {
|
|
1634
|
+
const node = daemon.networkNode;
|
|
1635
|
+
if (node?.active && node.proc && !node.proc.killed) {
|
|
1636
|
+
safeKill(node.proc);
|
|
1637
|
+
daemon.networkNode.status = 'stopping';
|
|
1638
|
+
pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
|
|
1639
|
+
broadcastNodeStatus();
|
|
1640
|
+
// Small grace window for the process to exit cleanly.
|
|
1641
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1642
|
+
}
|
|
1643
|
+
} catch { /* ignore */ }
|
|
1644
|
+
|
|
1645
|
+
broadcastUpdateProgress('fetching', `Fetching ${latest}...`, 5);
|
|
1646
|
+
|
|
1647
|
+
const fetchProc = spawn('git', ['-C', installPath, 'fetch', '--tags', '--force'], {
|
|
1648
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1649
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
1650
|
+
});
|
|
1651
|
+
let fetchErr = '';
|
|
1652
|
+
fetchProc.stderr.on('data', (c) => { fetchErr += c.toString(); });
|
|
1653
|
+
const fetchCode = await new Promise((r) => {
|
|
1654
|
+
fetchProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
1655
|
+
fetchProc.on('close', (code) => r({ code }));
|
|
1656
|
+
});
|
|
1657
|
+
if (fetchCode.code !== 0) {
|
|
1658
|
+
const hint = fetchErr.trim().split('\n').slice(-1)[0] || 'git fetch failed';
|
|
1659
|
+
return fail(`Fetch failed: ${hint}`);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
broadcastUpdateProgress('checkout', `Checking out ${latest}...`, 20);
|
|
1663
|
+
|
|
1664
|
+
const checkoutProc = spawn('git', ['-C', installPath, 'checkout', latest], {
|
|
1665
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1666
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
1667
|
+
});
|
|
1668
|
+
let checkoutErr = '';
|
|
1669
|
+
checkoutProc.stderr.on('data', (c) => { checkoutErr += c.toString(); });
|
|
1670
|
+
const checkoutCode = await new Promise((r) => {
|
|
1671
|
+
checkoutProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
1672
|
+
checkoutProc.on('close', (code) => r({ code }));
|
|
1673
|
+
});
|
|
1674
|
+
if (checkoutCode.code !== 0) {
|
|
1675
|
+
const hint = checkoutErr.trim().split('\n').slice(-1)[0] || 'git checkout failed';
|
|
1676
|
+
return fail(`Checkout failed: ${hint}`);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
|
|
1680
|
+
|
|
1681
|
+
let setup;
|
|
1682
|
+
try {
|
|
1683
|
+
setup = spawnSetupSh(installPath);
|
|
1684
|
+
} catch (spawnErr) {
|
|
1685
|
+
return fail(`Setup failed: ${spawnErr.message}`);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
daemon.networkInstall.proc = setup;
|
|
1689
|
+
|
|
1690
|
+
let stdoutBuf = '';
|
|
1691
|
+
setup.stdout.on('data', (chunk) => {
|
|
1692
|
+
stdoutBuf += chunk.toString();
|
|
1693
|
+
let idx;
|
|
1694
|
+
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
1695
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
1696
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
1697
|
+
if (!line || line[0] !== '{') continue;
|
|
1698
|
+
try {
|
|
1699
|
+
const event = JSON.parse(line);
|
|
1700
|
+
const step = typeof event.step === 'string' ? event.step : 'progress';
|
|
1701
|
+
const message = typeof event.message === 'string' ? event.message : '';
|
|
1702
|
+
const percent = Number.isFinite(event.percent) ? event.percent : null;
|
|
1703
|
+
broadcastUpdateProgress(step, message, percent);
|
|
1704
|
+
} catch { /* non-JSON line, ignore */ }
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
let stderrBuf = '';
|
|
1709
|
+
setup.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
1710
|
+
|
|
1711
|
+
const setupResult = await new Promise((r) => {
|
|
1712
|
+
setup.on('error', (e) => r({ code: -1, err: e.message }));
|
|
1713
|
+
setup.on('close', (code) => r({ code }));
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
if (setupResult.code !== 0) {
|
|
1717
|
+
let hint;
|
|
1718
|
+
if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
|
|
1719
|
+
hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
|
|
1720
|
+
} else {
|
|
1721
|
+
hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
1722
|
+
}
|
|
1723
|
+
return fail(`Setup failed: ${hint}`);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
daemon.config.networkBeta = {
|
|
1727
|
+
...(daemon.config.networkBeta || {}),
|
|
1728
|
+
version: latest,
|
|
1729
|
+
};
|
|
1730
|
+
await persistConfig();
|
|
1731
|
+
// Invalidate the update cache now that we've moved forward.
|
|
1732
|
+
networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
1733
|
+
daemon.networkUpdateAvailable = { latest, updateAvailable: false, installed: latest };
|
|
1734
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
1735
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
1736
|
+
broadcastUpdateProgress('done', `Updated to ${latest}`, 100);
|
|
1737
|
+
daemon.audit.log('network.update', { from: current, to: latest, path: installPath });
|
|
1738
|
+
daemon.networkInstall = { running: false };
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
fail(err?.message || 'Update failed');
|
|
1741
|
+
}
|
|
1742
|
+
})();
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// --- Wallet & earnings stubs (Base L2 — wired to real data post-mainnet) ---
|
|
1746
|
+
|
|
1747
|
+
app.get('/api/network/wallet', networkGate, (req, res) => {
|
|
1748
|
+
res.json({ connected: false, address: null, balance: '0.00', token: 'GROOVE', chain: 'base-l2' });
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
app.get('/api/network/earnings', networkGate, (req, res) => {
|
|
1752
|
+
res.json({ today: 0, thisWeek: 0, allTime: 0, history: [], currency: 'GROOVE' });
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
app.post('/api/network/wallet/connect', networkGate, (req, res) => {
|
|
1756
|
+
res.status(501).json({ error: 'Wallet connection not yet available. Coming with mainnet launch.' });
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
app.get('/api/network/node/identity', networkGate, (req, res) => {
|
|
1760
|
+
const node = daemon.networkNode;
|
|
1761
|
+
res.json({
|
|
1762
|
+
nodeId: node?.nodeId || null,
|
|
1763
|
+
address: node?.nodeId || null,
|
|
1764
|
+
startedAt: node?.startedAt || null,
|
|
1765
|
+
uptime: node?.startedAt ? Math.floor((Date.now() - node.startedAt) / 1000) : 0,
|
|
1766
|
+
});
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
// Startup hook — called from index.js once the server is up. Non-blocking;
|
|
1770
|
+
// updates daemon.networkUpdateAvailable and broadcasts so the GUI can badge.
|
|
1771
|
+
daemon.checkNetworkUpdate = async function checkNetworkUpdate() {
|
|
1772
|
+
const hasInstall = daemon.config?.networkBeta?.installed || existsSync(resolve(networkRoot(), 'setup.sh'));
|
|
1773
|
+
if (!hasInstall) return;
|
|
1774
|
+
try {
|
|
1775
|
+
const latest = await getLatestNetworkTag(true);
|
|
1776
|
+
if (!latest) return;
|
|
1777
|
+
const installed = getInstalledNetworkVersion();
|
|
1778
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
1779
|
+
daemon.networkUpdateAvailable = { installed, latest, updateAvailable };
|
|
1780
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
1781
|
+
} catch { /* non-fatal */ }
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
}
|