@gridlock-compute/native-worker 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worker.js ADDED
@@ -0,0 +1,437 @@
1
+ import WebSocket from "ws";
2
+ import { resolveRegistrationAttestationQuote } from "./attestation-quote.js";
3
+ import { printStartupBanner } from "./banner.js";
4
+ import { createDashboard, isDashboardMode, plainLog } from "./dashboard.js";
5
+ import { wsUrl, MAX_OUTPUT_TOKENS } from "./config.js";
6
+ import { detectHardwareTier } from "./gpu.js";
7
+ import { getActiveModel, resolveInferenceBackend, runBenchmark, runInference, } from "./inference.js";
8
+ import { computeJobAttestationHash } from "./attestation.js";
9
+ import { acquireWorkerLock } from "./lock.js";
10
+ import { startLiveStatsPoll } from "./live-stats.js";
11
+ import { shortWallet, parseWorkerWallet } from "./wallet.js";
12
+ /** Keep REST heartbeat under backend AutoGate threshold (120s). */
13
+ const HEARTBEAT_INTERVAL_MS = 15_000;
14
+ /** WebSocket protocol + app pings — proxies (e.g. Cloudflare) drop idle sockets ~100–120s. */
15
+ const WS_PING_INTERVAL_MS = 25_000;
16
+ const RECONNECT_DELAY_MS = 3_000;
17
+ const DUPLICATE_DISCONNECT_THRESHOLD = 3;
18
+ async function apiPost(base, path, body) {
19
+ const res = await fetch(`${base.replace(/\/$/, "")}${path}`, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify(body),
23
+ });
24
+ if (!res.ok) {
25
+ const text = await res.text();
26
+ throw new Error(`${path} failed (${res.status}): ${text.slice(0, 200)}`);
27
+ }
28
+ return res.json();
29
+ }
30
+ async function sendHeartbeat(wallet, backendUrl) {
31
+ await apiPost(backendUrl, "/v1/workers/heartbeat", { worker_address: wallet });
32
+ }
33
+ async function ensureWorkerActive(wallet, backendUrl) {
34
+ const base = backendUrl.replace(/\/$/, "");
35
+ const res = await fetch(`${base}/v1/workers/${wallet}/status`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ status: "Active" }),
39
+ });
40
+ if (!res.ok) {
41
+ const text = await res.text();
42
+ throw new Error(`Set worker Active failed (${res.status}): ${text.slice(0, 200)}`);
43
+ }
44
+ const data = (await res.json());
45
+ return data.status ?? "Active";
46
+ }
47
+ async function fetchWorkerStats(wallet, backendUrl) {
48
+ try {
49
+ const res = await fetch(`${backendUrl.replace(/\/$/, "")}/v1/workers/${wallet}`);
50
+ if (!res.ok)
51
+ return { jobsToday: 0, tokensToday: 0, workerStatus: "Active" };
52
+ const data = (await res.json());
53
+ return {
54
+ jobsToday: Number(data.jobs_today ?? 0),
55
+ tokensToday: Number(data.tokens_today ?? 0),
56
+ workerStatus: data.status ?? "Active",
57
+ };
58
+ }
59
+ catch {
60
+ return { jobsToday: 0, tokensToday: 0, workerStatus: "Active" };
61
+ }
62
+ }
63
+ async function ensureRegistered(wallet, backendUrl, hardwareTier) {
64
+ try {
65
+ const res = await fetch(`${backendUrl.replace(/\/$/, "")}/v1/workers/${wallet}`);
66
+ if (res.ok) {
67
+ await sendHeartbeat(wallet, backendUrl);
68
+ return;
69
+ }
70
+ }
71
+ catch {
72
+ /* register below */
73
+ }
74
+ const teeCapable = process.env.GRIDLOCK_TEE_CAPABLE === "true";
75
+ const attestationQuote = await resolveRegistrationAttestationQuote(backendUrl, wallet, teeCapable);
76
+ const body = {
77
+ operator_address: wallet,
78
+ earnings_wallet: process.env.GRIDLOCK_EARNINGS_WALLET?.trim() || wallet,
79
+ role: process.env.GRIDLOCK_ROLE ?? "Prefill",
80
+ hardware_tier: hardwareTier,
81
+ tee_capable: teeCapable,
82
+ is_confidential: teeCapable,
83
+ endpoint: `native://${hardwareTier.toLowerCase().replace(/\s+/g, "-")}`,
84
+ };
85
+ if (attestationQuote)
86
+ body.attestation_quote = attestationQuote;
87
+ try {
88
+ await apiPost(backendUrl, "/v1/workers/register", body);
89
+ }
90
+ catch (error) {
91
+ const msg = error instanceof Error ? error.message : String(error);
92
+ if (msg.includes("(409)")) {
93
+ await sendHeartbeat(wallet, backendUrl);
94
+ return;
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+ function log(msg) {
100
+ console.log(`[${new Date().toISOString()}] ${msg}`);
101
+ }
102
+ function sleep(ms) {
103
+ return new Promise((resolve) => setTimeout(resolve, ms));
104
+ }
105
+ async function executeJobBody(ws, ctx, job) {
106
+ const { wallet, dashboard } = ctx;
107
+ const { jobId, maxTokens, messages, confidential } = job;
108
+ const jobStartedAt = performance.now();
109
+ try {
110
+ const result = await runInference(messages, {
111
+ maxTokens,
112
+ onProgress: ({ tokens, maxTokens: limit }) => {
113
+ dashboard.setJobProgress(tokens, limit);
114
+ },
115
+ });
116
+ const attestationHash = confidential
117
+ ? computeJobAttestationHash(jobId, wallet, result.content)
118
+ : null;
119
+ const elapsedMs = performance.now() - jobStartedAt;
120
+ dashboard.note(`Job ${jobId.slice(0, 12)}… done (${result.tokens} tokens)`);
121
+ dashboard.clearJob(jobId, true, result.tokens, elapsedMs);
122
+ ws.send(JSON.stringify({
123
+ type: "job:complete",
124
+ job_id: jobId,
125
+ response: result.content,
126
+ tokens_generated: result.tokens,
127
+ ttft_ms: result.ttftMs,
128
+ tpot_ms: result.tpotMs,
129
+ attestation_hash: attestationHash,
130
+ }));
131
+ }
132
+ catch (e) {
133
+ const elapsedMs = performance.now() - jobStartedAt;
134
+ dashboard.note(`Job ${jobId.slice(0, 12)}… failed`);
135
+ dashboard.clearJob(jobId, false, 0, elapsedMs);
136
+ ws.send(JSON.stringify({
137
+ type: "job:error",
138
+ job_id: jobId,
139
+ error: e instanceof Error ? e.message : "Inference failed",
140
+ }));
141
+ plainLog(`Job ${jobId.slice(0, 12)}… failed: ${e instanceof Error ? e.message : String(e)}`);
142
+ }
143
+ }
144
+ function runWebSocketSession(ctx, opts) {
145
+ const { wallet, backendUrl, modelName, tokPerSec, dashboard, onAlreadyConnected } = ctx;
146
+ let jobChain = Promise.resolve();
147
+ let jobsInFlight = 0;
148
+ const enqueueJob = (ws, job) => {
149
+ jobsInFlight += 1;
150
+ dashboard.setJob(job.jobId, job.maxTokens);
151
+ dashboard.note(`Job received ${job.jobId.slice(0, 12)}…`);
152
+ jobChain = jobChain
153
+ .then(() => executeJobBody(ws, ctx, job))
154
+ .catch((e) => {
155
+ dashboard.note(`Job queue error: ${e instanceof Error ? e.message : String(e)}`);
156
+ plainLog(`Job queue error: ${e instanceof Error ? e.message : String(e)}`);
157
+ })
158
+ .finally(() => {
159
+ jobsInFlight = Math.max(0, jobsInFlight - 1);
160
+ });
161
+ };
162
+ const hasActiveWork = () => jobsInFlight > 0;
163
+ let heartbeatTimer = null;
164
+ let pingTimer = null;
165
+ let connectedAt = null;
166
+ const cleanup = () => {
167
+ if (heartbeatTimer)
168
+ clearInterval(heartbeatTimer);
169
+ if (pingTimer)
170
+ clearInterval(pingTimer);
171
+ heartbeatTimer = null;
172
+ pingTimer = null;
173
+ };
174
+ if (opts.isReconnect) {
175
+ dashboard.setConnection("reconnecting");
176
+ }
177
+ else {
178
+ dashboard.setConnection("starting");
179
+ }
180
+ return new Promise((resolve) => {
181
+ const ws = new WebSocket(wsUrl(backendUrl));
182
+ ws.on("open", () => {
183
+ connectedAt = Date.now();
184
+ dashboard.setConnection("connected");
185
+ ws.send(JSON.stringify({
186
+ type: "worker:register",
187
+ worker_address: wallet,
188
+ worker_type: "native",
189
+ model: modelName,
190
+ tok_per_sec: tokPerSec,
191
+ }));
192
+ dashboard.note("Registered for jobs");
193
+ void sendHeartbeat(wallet, backendUrl).catch((e) => {
194
+ dashboard.note(`Heartbeat failed: ${e instanceof Error ? e.message : String(e)}`);
195
+ plainLog(`Heartbeat failed: ${e instanceof Error ? e.message : String(e)}`);
196
+ });
197
+ heartbeatTimer = setInterval(() => {
198
+ void sendHeartbeat(wallet, backendUrl).catch((e) => {
199
+ dashboard.note(`Heartbeat failed: ${e instanceof Error ? e.message : String(e)}`);
200
+ plainLog(`Heartbeat failed: ${e instanceof Error ? e.message : String(e)}`);
201
+ });
202
+ }, HEARTBEAT_INTERVAL_MS);
203
+ pingTimer = setInterval(() => {
204
+ if (ws.readyState !== WebSocket.OPEN)
205
+ return;
206
+ ws.ping();
207
+ ws.send(JSON.stringify({ type: "ping" }));
208
+ }, WS_PING_INTERVAL_MS);
209
+ });
210
+ ws.on("message", (raw) => {
211
+ let msg;
212
+ try {
213
+ msg = JSON.parse(String(raw));
214
+ }
215
+ catch (e) {
216
+ dashboard.note(`Message error: ${e instanceof Error ? e.message : String(e)}`);
217
+ plainLog(`Message error: ${e instanceof Error ? e.message : String(e)}`);
218
+ return;
219
+ }
220
+ if (msg.type === "error") {
221
+ const code = String(msg.code ?? "");
222
+ if (code === "worker_already_connected") {
223
+ dashboard.note("Wallet already connected — stop the other worker instance");
224
+ plainLog("Rejected: this wallet already has an active worker. Stop the other instance before starting a new one.");
225
+ onAlreadyConnected();
226
+ return;
227
+ }
228
+ dashboard.note(`Error: ${String(msg.message ?? "unknown")}`);
229
+ plainLog(`Error: ${String(msg.message ?? "unknown")}`);
230
+ return;
231
+ }
232
+ if (msg.type === "stats:update") {
233
+ const hasWorkerStats = msg.jobs_today !== undefined
234
+ || msg.tokens_today !== undefined
235
+ || msg.jobs_failed_today !== undefined
236
+ || msg.worker_inflight !== undefined;
237
+ if (hasWorkerStats) {
238
+ dashboard.applyLiveStats({
239
+ jobsToday: Number(msg.jobs_today ?? 0),
240
+ tokensToday: Number(msg.tokens_today ?? 0),
241
+ jobsFailedToday: Number(msg.jobs_failed_today ?? 0),
242
+ workerStatus: String(msg.worker_status ?? "Active"),
243
+ wsTokPerSec: tokPerSec,
244
+ queueJobs: Number(msg.jobs_in_queue ?? 0),
245
+ inflightJobs: Number(msg.jobs_inflight ?? 0),
246
+ workerInflightJobs: Number(msg.worker_inflight ?? 0),
247
+ });
248
+ }
249
+ else {
250
+ dashboard.update({
251
+ queueJobs: Number(msg.jobs_in_queue ?? 0),
252
+ inflightJobs: Number(msg.jobs_inflight ?? 0),
253
+ });
254
+ }
255
+ return;
256
+ }
257
+ if (msg.type === "pong" || msg.type === "connected" || msg.type === "worker:registered") {
258
+ return;
259
+ }
260
+ if (msg.type === "job:new") {
261
+ enqueueJob(ws, {
262
+ jobId: String(msg.job_id),
263
+ maxTokens: Number(msg.max_tokens ?? MAX_OUTPUT_TOKENS),
264
+ messages: msg.messages ?? [],
265
+ confidential: msg.confidential === true || msg.sla_tier === "confidential",
266
+ });
267
+ }
268
+ });
269
+ ws.on("close", (code, reason) => {
270
+ cleanup();
271
+ const livedMs = connectedAt ? Date.now() - connectedAt : 0;
272
+ if (livedMs >= 5000) {
273
+ opts.onStableConnection();
274
+ }
275
+ else if (!hasActiveWork() && opts.onQuickDisconnect()) {
276
+ dashboard.note("Another worker instance is using this wallet — exiting");
277
+ plainLog("Disconnected repeatedly — another worker-cli process is registered with this wallet. Stop the duplicate instance.");
278
+ dashboard.setConnection("disconnected");
279
+ resolve();
280
+ return;
281
+ }
282
+ dashboard.setConnection("disconnected");
283
+ const detail = reason.toString() ? ` (${code} ${reason})` : ` (${code})`;
284
+ dashboard.note(`Disconnected from router${detail}`);
285
+ resolve();
286
+ });
287
+ ws.on("error", (err) => {
288
+ dashboard.note(`WebSocket error: ${err.message}`);
289
+ plainLog(`WebSocket error: ${err.message}`);
290
+ });
291
+ });
292
+ }
293
+ export async function startWorker(options) {
294
+ const { wallet: rawWallet, backendUrl, benchmarkOnly, inference, model } = options;
295
+ const wallet = parseWorkerWallet(rawWallet);
296
+ const dashboardMode = !benchmarkOnly && isDashboardMode();
297
+ const bannerLines = printStartupBanner();
298
+ const hardwareTier = await detectHardwareTier();
299
+ const backend = await resolveInferenceBackend(inference, model);
300
+ if (!dashboardMode) {
301
+ log(`Wallet: ${shortWallet(wallet)}`);
302
+ log(`API: ${backendUrl}`);
303
+ log(`Hardware: ${hardwareTier}`);
304
+ log(`Inference: ${backend} (${getActiveModel()})`);
305
+ log("Running benchmark…");
306
+ }
307
+ let dashboard = null;
308
+ if (dashboardMode) {
309
+ dashboard = createDashboard({
310
+ wallet: shortWallet(wallet),
311
+ api: backendUrl.replace(/^https?:\/\//, ""),
312
+ hardware: hardwareTier,
313
+ model: getActiveModel(),
314
+ inference: backend,
315
+ benchmarkTokPerSec: 0,
316
+ connection: "starting",
317
+ workerStatus: "Starting",
318
+ jobsCompleted: 0,
319
+ jobsFailed: 0,
320
+ jobsToday: 0,
321
+ tokensTodayBase: 0,
322
+ liveTokPerSec: null,
323
+ queueJobs: null,
324
+ inflightJobs: null,
325
+ workerInflightJobs: null,
326
+ currentJob: null,
327
+ }, { anchorRow: bannerLines + 1 });
328
+ dashboard.start();
329
+ dashboard.note("Running benchmark…");
330
+ }
331
+ const tokPerSec = await runBenchmark();
332
+ if (!dashboardMode) {
333
+ log(`Benchmark: ${tokPerSec} tok/s`);
334
+ }
335
+ else if (dashboard) {
336
+ dashboard.update({ benchmarkTokPerSec: tokPerSec });
337
+ dashboard.note("Registering with router…");
338
+ }
339
+ if (benchmarkOnly) {
340
+ dashboard?.stop();
341
+ return;
342
+ }
343
+ const releaseLock = acquireWorkerLock(wallet);
344
+ await ensureRegistered(wallet, backendUrl, hardwareTier);
345
+ const workerStats = await fetchWorkerStats(wallet, backendUrl);
346
+ const workerStatus = await ensureWorkerActive(wallet, backendUrl);
347
+ if (!dashboard) {
348
+ dashboard = createDashboard({
349
+ wallet: shortWallet(wallet),
350
+ api: backendUrl.replace(/^https?:\/\//, ""),
351
+ hardware: hardwareTier,
352
+ model: getActiveModel(),
353
+ inference: backend,
354
+ benchmarkTokPerSec: tokPerSec,
355
+ connection: "starting",
356
+ workerStatus,
357
+ jobsCompleted: 0,
358
+ jobsFailed: 0,
359
+ jobsToday: workerStats.jobsToday,
360
+ tokensTodayBase: workerStats.tokensToday,
361
+ liveTokPerSec: null,
362
+ queueJobs: null,
363
+ inflightJobs: null,
364
+ workerInflightJobs: null,
365
+ currentJob: null,
366
+ });
367
+ dashboard.start();
368
+ }
369
+ else {
370
+ dashboard.update({
371
+ benchmarkTokPerSec: tokPerSec,
372
+ workerStatus,
373
+ jobsToday: workerStats.jobsToday,
374
+ tokensTodayBase: workerStats.tokensToday,
375
+ });
376
+ }
377
+ dashboard.note("Worker online — waiting for jobs");
378
+ let running = true;
379
+ const stopLiveStats = startLiveStatsPoll(wallet, backendUrl, dashboard, () => running);
380
+ const session = {
381
+ wallet,
382
+ backendUrl,
383
+ modelName: getActiveModel(),
384
+ tokPerSec,
385
+ dashboard,
386
+ onAlreadyConnected: () => {
387
+ running = false;
388
+ },
389
+ };
390
+ let quickDisconnects = 0;
391
+ let hadSession = false;
392
+ const shutdown = () => {
393
+ running = false;
394
+ stopLiveStats();
395
+ dashboard.note("Shutting down…");
396
+ dashboard.stop();
397
+ releaseLock();
398
+ plainLog("Shutting down…");
399
+ };
400
+ process.once("SIGINT", shutdown);
401
+ process.once("SIGTERM", shutdown);
402
+ while (running) {
403
+ try {
404
+ await runWebSocketSession(session, {
405
+ isReconnect: hadSession,
406
+ onQuickDisconnect: () => {
407
+ quickDisconnects += 1;
408
+ if (quickDisconnects >= DUPLICATE_DISCONNECT_THRESHOLD) {
409
+ running = false;
410
+ return true;
411
+ }
412
+ return false;
413
+ },
414
+ onStableConnection: () => {
415
+ quickDisconnects = 0;
416
+ },
417
+ });
418
+ hadSession = true;
419
+ }
420
+ catch (e) {
421
+ dashboard.note(`Session error: ${e instanceof Error ? e.message : String(e)}`);
422
+ plainLog(`Session error: ${e instanceof Error ? e.message : String(e)}`);
423
+ }
424
+ if (!running)
425
+ break;
426
+ if (quickDisconnects >= DUPLICATE_DISCONNECT_THRESHOLD) {
427
+ running = false;
428
+ break;
429
+ }
430
+ dashboard.setConnection("reconnecting");
431
+ dashboard.note(`Reconnecting in ${RECONNECT_DELAY_MS / 1000}s…`);
432
+ await sleep(RECONNECT_DELAY_MS);
433
+ }
434
+ dashboard.stop();
435
+ stopLiveStats();
436
+ releaseLock();
437
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@gridlock-compute/native-worker",
3
+ "version": "0.1.1",
4
+ "description": "Native CLI worker for the Gridlock inference network. Connects over WebSocket and runs jobs via Ollama or vLLM.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "gridlock-native-worker": "bin/gridlock-native-worker.cjs"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Gridlockcompute/worker-cli.git"
14
+ },
15
+ "homepage": "https://grid-lock.tech",
16
+ "bugs": {
17
+ "url": "https://github.com/Gridlockcompute/worker-cli/issues"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "prepack": "npm run build",
25
+ "start": "node dist/index.js",
26
+ "dev": "tsx src/index.ts"
27
+ },
28
+ "keywords": [
29
+ "gridlock",
30
+ "gridlock-compute",
31
+ "inference",
32
+ "gpu",
33
+ "ollama",
34
+ "vllm",
35
+ "worker",
36
+ "evm",
37
+ "robinhood"
38
+ ],
39
+ "dependencies": {
40
+ "commander": "^12.1.0",
41
+ "viem": "^2.23.2",
42
+ "ws": "^8.21.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.20.1",
46
+ "@types/ws": "^8.18.1",
47
+ "tsx": "^4.23.0",
48
+ "typescript": "^5.9.3"
49
+ },
50
+ "engines": {
51
+ "node": ">=18.0.0"
52
+ },
53
+ "files": [
54
+ "dist",
55
+ "bin",
56
+ "README.md"
57
+ ]
58
+ }