darkmesh-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +89 -0
- package/assets/darksol-banner.png +0 -0
- package/package.json +35 -0
- package/src/cli.js +366 -0
- package/src/client.js +565 -0
- package/src/config.js +64 -0
- package/src/identity.js +26 -0
- package/src/index.js +400 -0
- package/src/operator.js +113 -0
- package/src/providers.js +19 -0
- package/src/service.js +148 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { createEnvelope, EVENT_TYPES } from '../../../packages/protocol/src/index.js';
|
|
3
|
+
import { createId, createKeyPair, createProviderFingerprintHash, decryptFromCoordinator, nowIso, safeJsonParse, signPayload } from '../../../packages/shared/src/index.js';
|
|
4
|
+
import { createOperatorBindingAuth } from './operator.js';
|
|
5
|
+
|
|
6
|
+
export class MemoryProviderAdapter {
|
|
7
|
+
constructor(models = ['qwen3:latest'], options = {}) {
|
|
8
|
+
this.provider = 'memory-provider';
|
|
9
|
+
this.providerType = 'memory';
|
|
10
|
+
this.models = models;
|
|
11
|
+
this.delayMs = options.delayMs || 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async listModels() {
|
|
15
|
+
return this.models;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getFingerprint() {
|
|
19
|
+
return {
|
|
20
|
+
providerType: this.providerType,
|
|
21
|
+
providerLabel: this.provider,
|
|
22
|
+
models: this.models,
|
|
23
|
+
transport: 'in-memory'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async generate({ prompt, model, signal }) {
|
|
28
|
+
if (this.delayMs > 0) {
|
|
29
|
+
await abortableDelay(this.delayMs, signal);
|
|
30
|
+
}
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
throw new Error('job_cancelled');
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
model,
|
|
36
|
+
output: `[${model}] ${prompt}`
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async runChallenge({ prompt, expectedModel, signal }) {
|
|
41
|
+
const result = await this.generate({ prompt, model: expectedModel || this.models[0], signal });
|
|
42
|
+
return {
|
|
43
|
+
model: result.model,
|
|
44
|
+
output: result.output
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class OpenAICompatibleAdapter {
|
|
50
|
+
constructor({ baseUrl, model = 'qwen3:latest', providerLabel = 'openai-compatible', headers = {} }) {
|
|
51
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
52
|
+
this.provider = providerLabel;
|
|
53
|
+
this.providerType = 'openai-compatible';
|
|
54
|
+
this.defaultModel = model;
|
|
55
|
+
this.headers = headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listModels() {
|
|
59
|
+
const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers });
|
|
60
|
+
if (!res.ok) throw new Error(`model_list_failed_${res.status}`);
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return (data.data || []).map(item => item.id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getFingerprint() {
|
|
66
|
+
const models = await this.listModels();
|
|
67
|
+
return {
|
|
68
|
+
providerType: this.providerType,
|
|
69
|
+
providerLabel: this.provider,
|
|
70
|
+
baseUrl: this.baseUrl,
|
|
71
|
+
defaultModel: this.defaultModel,
|
|
72
|
+
models,
|
|
73
|
+
authMode: Object.keys(this.headers || {}).length ? 'custom-headers' : 'none'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async generate({ prompt, model, signal }) {
|
|
78
|
+
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json', ...this.headers },
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model: model || this.defaultModel,
|
|
83
|
+
messages: [{ role: 'user', content: prompt }],
|
|
84
|
+
stream: false
|
|
85
|
+
}),
|
|
86
|
+
signal
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) throw new Error(`generation_failed_${res.status}`);
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
return {
|
|
91
|
+
model: data.model || model || this.defaultModel,
|
|
92
|
+
output: data.choices?.[0]?.message?.content || ''
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async runChallenge({ prompt, expectedModel, signal }) {
|
|
97
|
+
const result = await this.generate({ prompt, model: expectedModel || this.defaultModel, signal });
|
|
98
|
+
return {
|
|
99
|
+
model: result.model,
|
|
100
|
+
output: result.output
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function abortableDelay(ms, signal) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
if (signal?.aborted) {
|
|
108
|
+
reject(new Error('job_cancelled'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
cleanup();
|
|
113
|
+
resolve();
|
|
114
|
+
}, ms);
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
cleanup();
|
|
118
|
+
reject(new Error('job_cancelled'));
|
|
119
|
+
};
|
|
120
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
|
121
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class DarkMeshNode {
|
|
126
|
+
constructor(options = {}) {
|
|
127
|
+
const operatorIdExplicit = Object.prototype.hasOwnProperty.call(options, 'operatorId');
|
|
128
|
+
this.options = {
|
|
129
|
+
coordinatorBaseUrl: 'http://127.0.0.1:8787',
|
|
130
|
+
operatorId: 'operator-local',
|
|
131
|
+
label: createId('node-label'),
|
|
132
|
+
adapter: new MemoryProviderAdapter(),
|
|
133
|
+
requestedModel: null,
|
|
134
|
+
privacyClass: 'class-a-public-safe',
|
|
135
|
+
privacySecret: null,
|
|
136
|
+
heartbeatIntervalMs: 500,
|
|
137
|
+
...options
|
|
138
|
+
};
|
|
139
|
+
this.keyPair = this.options.keyPair || createKeyPair();
|
|
140
|
+
if (!operatorIdExplicit && this.options.operatorId === 'operator-local') {
|
|
141
|
+
this.options.operatorId = createId('operator');
|
|
142
|
+
}
|
|
143
|
+
this.operatorKeyPair = this.options.operatorKeyPair || this.keyPair;
|
|
144
|
+
this.nodeId = this.options.nodeId || null;
|
|
145
|
+
this.wsPath = this.options.wsPath || null;
|
|
146
|
+
this.request = null;
|
|
147
|
+
this.socket = null;
|
|
148
|
+
this.response = null;
|
|
149
|
+
this.buffer = '';
|
|
150
|
+
this.activeJobs = new Map();
|
|
151
|
+
this.heartbeatTimer = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async register() {
|
|
155
|
+
if (this.operatorKeyPair?.publicKey) {
|
|
156
|
+
const operatorRegisterRes = await fetch(`${this.options.coordinatorBaseUrl}/v1/operators/register`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'content-type': 'application/json' },
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
operatorId: this.options.operatorId,
|
|
161
|
+
label: this.options.label,
|
|
162
|
+
publicKey: this.operatorKeyPair.publicKey
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
if (!operatorRegisterRes.ok && operatorRegisterRes.status !== 200 && operatorRegisterRes.status !== 201) {
|
|
166
|
+
throw new Error(`operator_register_failed_${operatorRegisterRes.status}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const operatorAuth = this.operatorKeyPair
|
|
171
|
+
? await createOperatorBindingAuth({
|
|
172
|
+
coordinatorUrl: this.options.coordinatorBaseUrl,
|
|
173
|
+
operator: {
|
|
174
|
+
operatorId: this.options.operatorId,
|
|
175
|
+
label: this.options.label,
|
|
176
|
+
publicKey: this.operatorKeyPair.publicKey,
|
|
177
|
+
privateKey: this.operatorKeyPair.privateKey
|
|
178
|
+
},
|
|
179
|
+
nodeId: this.nodeId,
|
|
180
|
+
nodePublicKey: this.keyPair.publicKey,
|
|
181
|
+
label: this.options.label
|
|
182
|
+
})
|
|
183
|
+
: null;
|
|
184
|
+
const res = await fetch(`${this.options.coordinatorBaseUrl}/v1/nodes/register`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'content-type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
nodeId: this.nodeId,
|
|
189
|
+
operatorId: this.options.operatorId,
|
|
190
|
+
label: this.options.label,
|
|
191
|
+
publicKey: this.keyPair.publicKey,
|
|
192
|
+
privacyClass: this.options.privacyClass,
|
|
193
|
+
operatorAuth
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) throw new Error(`node_register_failed_${res.status}`);
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
this.nodeId = data.nodeId;
|
|
199
|
+
this.wsPath = data.wsPath;
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async connect() {
|
|
204
|
+
if (!this.nodeId || !this.wsPath) await this.register();
|
|
205
|
+
const url = new URL(this.wsPath, this.options.coordinatorBaseUrl);
|
|
206
|
+
await this.openSocket(url);
|
|
207
|
+
const models = await this.options.adapter.listModels();
|
|
208
|
+
const fingerprint = this.options.adapter.getFingerprint
|
|
209
|
+
? await this.options.adapter.getFingerprint()
|
|
210
|
+
: {
|
|
211
|
+
providerType: this.options.adapter.providerType,
|
|
212
|
+
providerLabel: this.options.adapter.provider,
|
|
213
|
+
models
|
|
214
|
+
};
|
|
215
|
+
const providerFingerprint = {
|
|
216
|
+
...fingerprint,
|
|
217
|
+
models
|
|
218
|
+
};
|
|
219
|
+
const claim = {
|
|
220
|
+
nodeId: this.nodeId,
|
|
221
|
+
operatorId: this.options.operatorId,
|
|
222
|
+
provider: this.options.adapter.provider,
|
|
223
|
+
providerType: this.options.adapter.providerType,
|
|
224
|
+
privacyClass: this.options.privacyClass,
|
|
225
|
+
models,
|
|
226
|
+
providerFingerprintHash: createProviderFingerprintHash(providerFingerprint),
|
|
227
|
+
timestamp: nowIso()
|
|
228
|
+
};
|
|
229
|
+
const signature = signPayload(this.keyPair.privateKey, JSON.stringify(claim));
|
|
230
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_HELLO, {
|
|
231
|
+
claim,
|
|
232
|
+
provider: this.options.adapter.provider,
|
|
233
|
+
providerType: this.options.adapter.providerType,
|
|
234
|
+
privacyClass: this.options.privacyClass,
|
|
235
|
+
models,
|
|
236
|
+
providerFingerprint,
|
|
237
|
+
signature
|
|
238
|
+
}));
|
|
239
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_MODELS, {
|
|
240
|
+
provider: this.options.adapter.provider,
|
|
241
|
+
providerType: this.options.adapter.providerType,
|
|
242
|
+
privacyClass: this.options.privacyClass,
|
|
243
|
+
models,
|
|
244
|
+
providerFingerprint
|
|
245
|
+
}));
|
|
246
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_PRIVACY_CAPS, {
|
|
247
|
+
privacyClass: this.options.privacyClass,
|
|
248
|
+
protectedTransport: Boolean(this.options.privacySecret)
|
|
249
|
+
}));
|
|
250
|
+
this.startHeartbeatLoop();
|
|
251
|
+
return { nodeId: this.nodeId, models };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
startHeartbeatLoop() {
|
|
255
|
+
this.stopHeartbeatLoop();
|
|
256
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_HEARTBEAT, { nodeId: this.nodeId }));
|
|
257
|
+
this.heartbeatTimer = setInterval(() => {
|
|
258
|
+
if (!this.request || this.request.destroyed) return;
|
|
259
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_HEARTBEAT, { nodeId: this.nodeId }));
|
|
260
|
+
}, this.options.heartbeatIntervalMs);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
stopHeartbeatLoop() {
|
|
264
|
+
if (this.heartbeatTimer) {
|
|
265
|
+
clearInterval(this.heartbeatTimer);
|
|
266
|
+
this.heartbeatTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async openSocket(url) {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const req = http.request({
|
|
273
|
+
hostname: url.hostname,
|
|
274
|
+
port: url.port,
|
|
275
|
+
path: url.pathname + url.search,
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
'content-type': 'application/x-ndjson'
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
this.request = req;
|
|
282
|
+
req.on('response', res => {
|
|
283
|
+
this.response = res;
|
|
284
|
+
res.setEncoding('utf8');
|
|
285
|
+
res.on('data', chunk => this.onData(chunk));
|
|
286
|
+
res.on('error', reject);
|
|
287
|
+
this.socket = req.socket;
|
|
288
|
+
resolve();
|
|
289
|
+
});
|
|
290
|
+
req.on('error', reject);
|
|
291
|
+
req.flushHeaders();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
onData(chunk) {
|
|
296
|
+
this.buffer += chunk;
|
|
297
|
+
let index;
|
|
298
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
299
|
+
const raw = this.buffer.slice(0, index).trim();
|
|
300
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
301
|
+
if (!raw) continue;
|
|
302
|
+
const message = safeJsonParse(raw);
|
|
303
|
+
if (!message) continue;
|
|
304
|
+
this.handleMessage(message).catch(error => {
|
|
305
|
+
if (message?.payload?.jobId) {
|
|
306
|
+
this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: error.message }));
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async handleMessage(message) {
|
|
313
|
+
if (message.type === EVENT_TYPES.SESSION_READY) return;
|
|
314
|
+
|
|
315
|
+
if (message.type === EVENT_TYPES.NODE_RUNTIME_CHALLENGE) {
|
|
316
|
+
const payload = message.payload || {};
|
|
317
|
+
const challengeId = payload.challengeId;
|
|
318
|
+
const controller = new AbortController();
|
|
319
|
+
try {
|
|
320
|
+
const proof = this.options.adapter.runChallenge
|
|
321
|
+
? await this.options.adapter.runChallenge({
|
|
322
|
+
prompt: payload.prompt,
|
|
323
|
+
expectedModel: payload.expectedModel,
|
|
324
|
+
signal: controller.signal
|
|
325
|
+
})
|
|
326
|
+
: await this.options.adapter.generate({
|
|
327
|
+
prompt: payload.prompt,
|
|
328
|
+
model: payload.expectedModel,
|
|
329
|
+
signal: controller.signal
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_RUNTIME_PROOF, {
|
|
333
|
+
challengeId,
|
|
334
|
+
model: proof.model,
|
|
335
|
+
output: proof.output
|
|
336
|
+
}));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.send(createEnvelope(EVENT_TYPES.NODE_RUNTIME_PROOF, {
|
|
339
|
+
challengeId,
|
|
340
|
+
error: error.message || 'runtime_challenge_failed'
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (message.type === EVENT_TYPES.JOB_CANCEL) {
|
|
347
|
+
const active = this.activeJobs.get(message.payload.jobId);
|
|
348
|
+
if (active) active.controller.abort();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (message.type === EVENT_TYPES.JOB_ASSIGN) {
|
|
353
|
+
this.send(createEnvelope(EVENT_TYPES.JOB_ACCEPT, { jobId: message.payload.jobId }));
|
|
354
|
+
const controller = new AbortController();
|
|
355
|
+
this.activeJobs.set(message.payload.jobId, { controller });
|
|
356
|
+
try {
|
|
357
|
+
let prompt = message.payload.prompt;
|
|
358
|
+
if (message.payload.protectedPayload) {
|
|
359
|
+
if (!this.options.privacySecret) {
|
|
360
|
+
throw new Error('protected_payload_not_supported');
|
|
361
|
+
}
|
|
362
|
+
prompt = decryptFromCoordinator(message.payload.protectedPayload, this.options.privacySecret);
|
|
363
|
+
}
|
|
364
|
+
const result = await this.options.adapter.generate({
|
|
365
|
+
prompt,
|
|
366
|
+
model: message.payload.requestedModel,
|
|
367
|
+
signal: controller.signal
|
|
368
|
+
});
|
|
369
|
+
if (controller.signal.aborted) {
|
|
370
|
+
this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: 'job_cancelled' }));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.send(createEnvelope(EVENT_TYPES.JOB_COMPLETE, { jobId: message.payload.jobId, result }));
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: error.message }));
|
|
376
|
+
} finally {
|
|
377
|
+
this.activeJobs.delete(message.payload.jobId);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
send(message) {
|
|
383
|
+
if (!this.request || this.request.destroyed) throw new Error('node_connection_not_open');
|
|
384
|
+
this.request.write(JSON.stringify(message) + '\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async close() {
|
|
388
|
+
this.stopHeartbeatLoop();
|
|
389
|
+
for (const active of this.activeJobs.values()) active.controller.abort();
|
|
390
|
+
this.activeJobs.clear();
|
|
391
|
+
if (this.request && !this.request.destroyed) this.request.destroy();
|
|
392
|
+
if (this.response && !this.response.destroyed) this.response.destroy();
|
|
393
|
+
if (this.socket && !this.socket.destroyed) this.socket.destroy();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
398
|
+
const node = new DarkMeshNode();
|
|
399
|
+
node.connect().then(info => console.log(JSON.stringify({ ok: true, ...info }, null, 2)));
|
|
400
|
+
}
|
package/src/operator.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createKeyPair, nowIso, signPayload } from '../../../packages/shared/src/index.js';
|
|
2
|
+
import { JsonFileStore } from '../../../packages/shared/src/store.js';
|
|
3
|
+
import { ensureDarkMeshHome, getOperatorIdentityPath } from './config.js';
|
|
4
|
+
|
|
5
|
+
function createOperatorStore(customHome) {
|
|
6
|
+
return new JsonFileStore(getOperatorIdentityPath(customHome));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadOperatorIdentity(customHome) {
|
|
10
|
+
const store = createOperatorStore(customHome);
|
|
11
|
+
return store.load(null);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function ensureOperatorIdentity(customHome, overrides = {}) {
|
|
15
|
+
await ensureDarkMeshHome(customHome);
|
|
16
|
+
const store = createOperatorStore(customHome);
|
|
17
|
+
const existing = await store.load(null);
|
|
18
|
+
const operatorId = overrides.operatorId || existing?.operatorId || 'operator-local';
|
|
19
|
+
const label = overrides.label || existing?.label || operatorId;
|
|
20
|
+
|
|
21
|
+
if (existing?.publicKey && existing?.privateKey && existing.operatorId === operatorId) {
|
|
22
|
+
const next = {
|
|
23
|
+
...existing,
|
|
24
|
+
label,
|
|
25
|
+
operatorId,
|
|
26
|
+
updatedAt: nowIso()
|
|
27
|
+
};
|
|
28
|
+
await store.save(next);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const keyPair = createKeyPair();
|
|
33
|
+
const createdAt = nowIso();
|
|
34
|
+
const identity = {
|
|
35
|
+
operatorId,
|
|
36
|
+
label,
|
|
37
|
+
publicKey: keyPair.publicKey,
|
|
38
|
+
privateKey: keyPair.privateKey,
|
|
39
|
+
createdAt,
|
|
40
|
+
updatedAt: createdAt
|
|
41
|
+
};
|
|
42
|
+
await store.save(identity);
|
|
43
|
+
return identity;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildOperatorBindingPayload({ operatorId, nodeId, nodePublicKey, label, challengeId, timestamp, nonce }) {
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
action: 'bind_node',
|
|
49
|
+
operatorId,
|
|
50
|
+
nodeId,
|
|
51
|
+
nodePublicKey,
|
|
52
|
+
label: label || null,
|
|
53
|
+
challengeId,
|
|
54
|
+
timestamp,
|
|
55
|
+
nonce
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function registerOperatorAccount({ coordinatorUrl, operator }) {
|
|
60
|
+
const res = await fetch(`${coordinatorUrl}/v1/operators/register`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
operatorId: operator.operatorId,
|
|
65
|
+
label: operator.label,
|
|
66
|
+
publicKey: operator.publicKey
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (!res.ok) throw new Error(data.error || `operator_register_failed_${res.status}`);
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchOperatorAccount({ coordinatorUrl, operatorId }) {
|
|
75
|
+
const res = await fetch(`${coordinatorUrl}/v1/operators/${encodeURIComponent(operatorId)}`);
|
|
76
|
+
if (res.status === 404) return null;
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
if (!res.ok) throw new Error(data.error || `operator_fetch_failed_${res.status}`);
|
|
79
|
+
return data.operator || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function createOperatorBindingAuth({ coordinatorUrl, operator, nodeId, nodePublicKey, label }) {
|
|
83
|
+
const challengeRes = await fetch(`${coordinatorUrl}/v1/operators/bind-challenge`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'content-type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
operatorId: operator.operatorId,
|
|
88
|
+
nodeId,
|
|
89
|
+
nodePublicKey,
|
|
90
|
+
label: label || null
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
const challengeData = await challengeRes.json();
|
|
94
|
+
if (!challengeRes.ok) throw new Error(challengeData.error || `operator_bind_challenge_failed_${challengeRes.status}`);
|
|
95
|
+
|
|
96
|
+
const payload = buildOperatorBindingPayload({
|
|
97
|
+
operatorId: operator.operatorId,
|
|
98
|
+
nodeId,
|
|
99
|
+
nodePublicKey,
|
|
100
|
+
label: label || null,
|
|
101
|
+
challengeId: challengeData.challengeId,
|
|
102
|
+
timestamp: challengeData.timestamp,
|
|
103
|
+
nonce: challengeData.nonce
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
operatorId: operator.operatorId,
|
|
108
|
+
challengeId: challengeData.challengeId,
|
|
109
|
+
timestamp: challengeData.timestamp,
|
|
110
|
+
nonce: challengeData.nonce,
|
|
111
|
+
signature: signPayload(operator.privateKey, payload)
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { MemoryProviderAdapter, OpenAICompatibleAdapter } from './index.js';
|
|
2
|
+
|
|
3
|
+
export function createProviderAdapter(options = {}) {
|
|
4
|
+
const providerType = options.providerType || 'memory';
|
|
5
|
+
|
|
6
|
+
if (providerType === 'memory') {
|
|
7
|
+
return new MemoryProviderAdapter(options.models || ['qwen3:latest']);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (providerType === 'openai-compatible' || providerType === 'ollama') {
|
|
11
|
+
return new OpenAICompatibleAdapter({
|
|
12
|
+
baseUrl: options.baseUrl || 'http://127.0.0.1:11434/v1',
|
|
13
|
+
model: options.model || 'qwen3:latest',
|
|
14
|
+
providerLabel: providerType
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw new Error(`unsupported_provider_${providerType}`);
|
|
19
|
+
}
|
package/src/service.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import fsPromises from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ensureDarkMeshHome, getDarkMeshHome } from './config.js';
|
|
5
|
+
|
|
6
|
+
function nowIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getServiceRuntimeDir(customHome) {
|
|
11
|
+
return path.join(getDarkMeshHome(customHome), 'runtime');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getServiceStatePath(customHome) {
|
|
15
|
+
return path.join(getServiceRuntimeDir(customHome), 'service.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getServiceLogPath(customHome) {
|
|
19
|
+
return path.join(getServiceRuntimeDir(customHome), 'service.log');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getServiceStopPath(customHome) {
|
|
23
|
+
return path.join(getServiceRuntimeDir(customHome), 'service.stop');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureServiceRuntime(customHome) {
|
|
27
|
+
await ensureDarkMeshHome(customHome);
|
|
28
|
+
await fsPromises.mkdir(getServiceRuntimeDir(customHome), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadServiceState(customHome) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fsPromises.readFile(getServiceStatePath(customHome), 'utf8');
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code === 'ENOENT') return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function saveServiceState(customHome, state) {
|
|
42
|
+
await ensureServiceRuntime(customHome);
|
|
43
|
+
const {
|
|
44
|
+
installed: _installed,
|
|
45
|
+
running: _running,
|
|
46
|
+
pidRunning: _pidRunning,
|
|
47
|
+
stopRequested: _stopRequested,
|
|
48
|
+
...persistable
|
|
49
|
+
} = state || {};
|
|
50
|
+
const nextState = {
|
|
51
|
+
...persistable,
|
|
52
|
+
updatedAt: nowIso(),
|
|
53
|
+
logPath: getServiceLogPath(customHome)
|
|
54
|
+
};
|
|
55
|
+
await fsPromises.writeFile(getServiceStatePath(customHome), `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
|
|
56
|
+
return nextState;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function clearServiceStopRequest(customHome) {
|
|
60
|
+
try {
|
|
61
|
+
await fsPromises.unlink(getServiceStopPath(customHome));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function writeServiceStopRequest(customHome) {
|
|
68
|
+
await ensureServiceRuntime(customHome);
|
|
69
|
+
await fsPromises.writeFile(getServiceStopPath(customHome), nowIso(), 'utf8');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function serviceStopRequested(customHome) {
|
|
73
|
+
try {
|
|
74
|
+
await fsPromises.access(getServiceStopPath(customHome));
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === 'ENOENT') return false;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isPidRunning(pid) {
|
|
83
|
+
if (!pid || !Number.isInteger(pid)) return false;
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function refreshServiceState(customHome) {
|
|
93
|
+
const state = await loadServiceState(customHome);
|
|
94
|
+
if (!state) {
|
|
95
|
+
return {
|
|
96
|
+
installed: false,
|
|
97
|
+
running: false,
|
|
98
|
+
status: 'stopped',
|
|
99
|
+
pid: null,
|
|
100
|
+
logPath: getServiceLogPath(customHome),
|
|
101
|
+
stopRequested: await serviceStopRequested(customHome)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const running = ['starting', 'running', 'stopping'].includes(state.status);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...state,
|
|
109
|
+
installed: true,
|
|
110
|
+
running,
|
|
111
|
+
pidRunning: state.pid ? isPidRunning(state.pid) : false,
|
|
112
|
+
stopRequested: await serviceStopRequested(customHome),
|
|
113
|
+
logPath: state.logPath || getServiceLogPath(customHome)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function openServiceLogFd(customHome) {
|
|
118
|
+
fs.mkdirSync(getServiceRuntimeDir(customHome), { recursive: true });
|
|
119
|
+
return fs.openSync(getServiceLogPath(customHome), 'a');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function readServiceLog(customHome, options = {}) {
|
|
123
|
+
const logPath = getServiceLogPath(customHome);
|
|
124
|
+
const tailLines = Math.max(1, Number(options.tailLines || 50));
|
|
125
|
+
try {
|
|
126
|
+
const text = await fsPromises.readFile(logPath, 'utf8');
|
|
127
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
128
|
+
const tail = lines.slice(-tailLines);
|
|
129
|
+
return {
|
|
130
|
+
exists: true,
|
|
131
|
+
logPath,
|
|
132
|
+
lineCount: lines.length,
|
|
133
|
+
tailLines,
|
|
134
|
+
text: tail.join('\n')
|
|
135
|
+
};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code === 'ENOENT') {
|
|
138
|
+
return {
|
|
139
|
+
exists: false,
|
|
140
|
+
logPath,
|
|
141
|
+
lineCount: 0,
|
|
142
|
+
tailLines,
|
|
143
|
+
text: ''
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|