@tiens.nguyen/gonext-local-worker 1.0.0 → 1.0.3

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/README.md CHANGED
@@ -11,7 +11,7 @@ You must create a **Worker API key** in the web app **Settings** (stored as a ha
11
11
  ## Install
12
12
 
13
13
  ```bash
14
- npm install -g @gomarsic/gonext-local-worker
14
+ npm install -g @tiens.nguyen/gonext-local-worker
15
15
  ```
16
16
 
17
17
  Or from source:
@@ -56,11 +56,52 @@ Options:
56
56
  ```bash
57
57
  gonext-local-worker --poll-ms 2000
58
58
  gonext-local-worker --api-base https://other-host.example
59
+ gonext-local-worker --mode webhook --webhook-port 5001
60
+ gonext-local-worker --mode both
59
61
  gonext-local-worker --help
60
62
  ```
61
63
 
62
64
  Leave this process **running** while you use async local models from the web app.
63
65
 
66
+ ## Push-dispatch mode (new option)
67
+
68
+ You now have 2 ways to update job status:
69
+
70
+ 1. **Polling mode** (existing): worker polls `/api/worker/jobs/next`.
71
+ 2. **Webhook mode** (new): API push-dispatches job payloads to your local worker endpoint.
72
+
73
+ Run local webhook endpoint:
74
+
75
+ ```bash
76
+ gonext-local-worker --mode webhook --webhook-port 5001
77
+ ```
78
+
79
+ or keep both (recommended transition):
80
+
81
+ ```bash
82
+ gonext-local-worker --mode both --webhook-port 5001
83
+ ```
84
+
85
+ Expose webhook with ngrok:
86
+
87
+ ```bash
88
+ ngrok http 5001
89
+ ```
90
+
91
+ Then in web app Settings set **Worker webhook URL** to:
92
+
93
+ `https://<your-ngrok-domain>/api/dispatch-job`
94
+
95
+ Local worker API endpoints on port 5001:
96
+
97
+ - `POST /api/chat` -> proxies to `http://localhost:11434/api/chat`
98
+ - `POST /api/generate` -> proxies to `http://localhost:11434/api/generate`
99
+ - `GET /api/tags` -> proxies to `http://localhost:11434/api/tags`
100
+ - `POST /api/dispatch-job` -> cloud dispatch entrypoint (token-protected)
101
+ - `GET /api/health`
102
+
103
+ Legacy non-prefixed routes (`/chat`, `/generate`, `/tags`, `/dispatch-job`, `/health`) are also available for compatibility.
104
+
64
105
  ## Run at login (macOS LaunchAgent)
65
106
 
66
107
  1. Copy `launchd/com.gonext.worker.plist.example` to `~/Library/LaunchAgents/com.gonext.worker.plist`.
@@ -13,21 +13,33 @@
13
13
  * ./.env (cwd)
14
14
  */
15
15
  import { homedir } from "node:os";
16
+ import os from "node:os";
17
+ import { createHash } from "node:crypto";
16
18
  import { join } from "node:path";
17
19
  import dotenv from "dotenv";
20
+ import express from "express";
18
21
  import OpenAI from "openai";
19
22
 
20
23
  dotenv.config({ path: join(homedir(), ".gonext", "worker.env") });
21
24
  dotenv.config();
22
25
 
23
26
  function parseArgs(argv) {
24
- const out = { help: false, pollMs: undefined, apiBase: undefined };
27
+ const out = {
28
+ help: false,
29
+ pollMs: undefined,
30
+ apiBase: undefined,
31
+ mode: undefined,
32
+ webhookPort: undefined,
33
+ };
25
34
  for (let i = 2; i < argv.length; i++) {
26
35
  const a = argv[i];
27
36
  if (a === "--help" || a === "-h") out.help = true;
28
37
  else if (a === "--poll-ms" && argv[i + 1])
29
38
  out.pollMs = Number(argv[++i]);
30
39
  else if (a === "--api-base" && argv[i + 1]) out.apiBase = argv[++i];
40
+ else if (a === "--mode" && argv[i + 1]) out.mode = argv[++i];
41
+ else if (a === "--webhook-port" && argv[i + 1])
42
+ out.webhookPort = Number(argv[++i]);
31
43
  }
32
44
  return out;
33
45
  }
@@ -44,13 +56,15 @@ Usage:
44
56
  Options:
45
57
  --poll-ms <ms> Idle poll interval (default 1500 or GONEXT_POLL_MS)
46
58
  --api-base <url> Override GONEXT_API_BASE
59
+ --mode <poll|webhook|both> Worker mode (default: poll)
60
+ --webhook-port <n> Local webhook port (default: 5001)
47
61
 
48
62
  Config files (optional):
49
63
  ~/.gonext/worker.env
50
64
  .env in current directory
51
65
 
52
- Install (from repo):
53
- cd tools/gonext-local-worker && npm install && npm link
66
+ Install:
67
+ npm install -g @tiens.nguyen/gonext-local-worker
54
68
 
55
69
  Then keep this running while you use the web app with local models.
56
70
  `);
@@ -72,6 +86,17 @@ const pollMs =
72
86
  (Number.isFinite(args.pollMs) && args.pollMs > 0
73
87
  ? args.pollMs
74
88
  : Number(process.env.GONEXT_POLL_MS ?? "1500")) || 1500;
89
+ const modeRaw = String(args.mode ?? process.env.GONEXT_WORKER_MODE ?? "poll");
90
+ const mode = ["poll", "webhook", "both"].includes(modeRaw)
91
+ ? modeRaw
92
+ : "poll";
93
+ const webhookPort =
94
+ (Number.isFinite(args.webhookPort) && args.webhookPort > 0
95
+ ? args.webhookPort
96
+ : Number(process.env.GONEXT_WEBHOOK_PORT ?? "5001")) || 5001;
97
+ const ollamaBase = (
98
+ process.env.GONEXT_LOCAL_OLLAMA_BASE ?? "http://127.0.0.1:11434"
99
+ ).replace(/\/+$/, "");
75
100
 
76
101
  if (!apiBase || !workerKey) {
77
102
  console.error(
@@ -80,6 +105,12 @@ if (!apiBase || !workerKey) {
80
105
  process.exit(1);
81
106
  }
82
107
 
108
+ const WORKER_VERSION = "1.0.0";
109
+ const WORKER_HOST = os.hostname();
110
+ const DISPATCH_TOKEN = createHash("sha256")
111
+ .update(workerKey, "utf8")
112
+ .digest("hex");
113
+
83
114
  function ts() {
84
115
  return new Date().toISOString();
85
116
  }
@@ -113,10 +144,31 @@ async function workerFetch(path, init = {}) {
113
144
  }
114
145
 
115
146
  let shuttingDown = false;
147
+ let activeJobId = "";
148
+ let lastError = "";
149
+ let webhookServer = null;
150
+
151
+ async function postHeartbeat(payload) {
152
+ try {
153
+ await workerFetch("/api/worker/heartbeat", {
154
+ method: "POST",
155
+ body: JSON.stringify({
156
+ workerVersion: WORKER_VERSION,
157
+ host: WORKER_HOST,
158
+ ...payload,
159
+ }),
160
+ });
161
+ } catch {
162
+ /* keep worker loop alive */
163
+ }
164
+ }
116
165
 
117
166
  async function runChatJob(job) {
118
167
  const { jobId, payload } = job;
119
168
  const start = Date.now();
169
+ activeJobId = jobId;
170
+ lastError = "";
171
+ await postHeartbeat({ state: "running", currentJobId: jobId });
120
172
  const patchRunning = await workerFetch(`/api/worker/jobs/${jobId}`, {
121
173
  method: "PATCH",
122
174
  body: JSON.stringify({ jobStatus: "running" }),
@@ -124,6 +176,13 @@ async function runChatJob(job) {
124
176
  if (!patchRunning.ok) {
125
177
  const t = await patchRunning.text().catch(() => "");
126
178
  console.error(`[${ts()}] PATCH running failed ${patchRunning.status}`, t);
179
+ await postHeartbeat({
180
+ state: "error",
181
+ currentJobId: jobId,
182
+ lastError: `PATCH running failed ${patchRunning.status}`,
183
+ });
184
+ lastError = `PATCH running failed ${patchRunning.status}`;
185
+ activeJobId = "";
127
186
  return;
128
187
  }
129
188
 
@@ -152,6 +211,12 @@ async function runChatJob(job) {
152
211
  console.log(
153
212
  `[${ts()}] completed job ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
154
213
  );
214
+ await postHeartbeat({
215
+ state: "idle",
216
+ currentJobId: "",
217
+ lastJobCompletedAt: new Date().toISOString(),
218
+ });
219
+ activeJobId = "";
155
220
  } catch (e) {
156
221
  const message = e instanceof Error ? e.message : String(e);
157
222
  await workerFetch(`/api/worker/jobs/${jobId}`, {
@@ -163,6 +228,13 @@ async function runChatJob(job) {
163
228
  }),
164
229
  });
165
230
  console.error(`[${ts()}] failed job ${jobId}:`, message);
231
+ await postHeartbeat({
232
+ state: "error",
233
+ currentJobId: jobId,
234
+ lastError: message,
235
+ });
236
+ lastError = message;
237
+ activeJobId = "";
166
238
  }
167
239
  }
168
240
 
@@ -183,18 +255,240 @@ function sleep(ms) {
183
255
  return new Promise((r) => setTimeout(r, ms));
184
256
  }
185
257
 
258
+ function requireDispatchToken(req, res) {
259
+ const token = req.header("x-gonext-dispatch-token") ?? "";
260
+ if (token !== DISPATCH_TOKEN) {
261
+ res.status(403).json({ error: "Invalid dispatch token." });
262
+ return false;
263
+ }
264
+ return true;
265
+ }
266
+
267
+ function toOllamaChatMessages(messages) {
268
+ return (messages ?? []).map((m) => ({
269
+ role: m.role,
270
+ content: m.content,
271
+ ...(Array.isArray(m.attachments) && m.attachments.length > 0
272
+ ? { images: m.attachments.map((a) => a.data) }
273
+ : {}),
274
+ }));
275
+ }
276
+
277
+ async function runOllamaAndMaybeUpdateJob(params) {
278
+ const { endpoint, requestBody, jobId, extractResultText } = params;
279
+ const hasJob = typeof jobId === "string" && jobId.length > 0;
280
+ const start = Date.now();
281
+ if (hasJob) {
282
+ activeJobId = jobId;
283
+ lastError = "";
284
+ await postHeartbeat({ state: "running", currentJobId: jobId });
285
+ await workerFetch(`/api/worker/jobs/${jobId}`, {
286
+ method: "PATCH",
287
+ body: JSON.stringify({ jobStatus: "running" }),
288
+ });
289
+ }
290
+ try {
291
+ const res = await fetch(`${ollamaBase}${endpoint}`, {
292
+ method: "POST",
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify({
295
+ stream: false,
296
+ ...requestBody,
297
+ }),
298
+ });
299
+ const raw = await res.text();
300
+ if (!res.ok) {
301
+ throw new Error(`Ollama ${endpoint} failed ${res.status}: ${raw}`);
302
+ }
303
+ const parsed = raw ? JSON.parse(raw) : {};
304
+ if (hasJob) {
305
+ await workerFetch(`/api/worker/jobs/${jobId}`, {
306
+ method: "PATCH",
307
+ body: JSON.stringify({
308
+ jobStatus: "completed",
309
+ resultText: extractResultText(parsed),
310
+ tokenCount: 1,
311
+ totalTimeSeconds: (Date.now() - start) / 1000,
312
+ }),
313
+ });
314
+ await postHeartbeat({
315
+ state: "idle",
316
+ currentJobId: "",
317
+ lastJobCompletedAt: new Date().toISOString(),
318
+ });
319
+ activeJobId = "";
320
+ }
321
+ return parsed;
322
+ } catch (e) {
323
+ if (hasJob) {
324
+ const message = e instanceof Error ? e.message : String(e);
325
+ await workerFetch(`/api/worker/jobs/${jobId}`, {
326
+ method: "PATCH",
327
+ body: JSON.stringify({
328
+ jobStatus: "failed",
329
+ errorMessage: message,
330
+ totalTimeSeconds: (Date.now() - start) / 1000,
331
+ }),
332
+ });
333
+ await postHeartbeat({
334
+ state: "error",
335
+ currentJobId: jobId,
336
+ lastError: message,
337
+ });
338
+ lastError = message;
339
+ activeJobId = "";
340
+ }
341
+ throw e;
342
+ }
343
+ }
344
+
345
+ function startWebhookServer() {
346
+ const app = express();
347
+ app.use(express.json({ limit: "10mb" }));
348
+
349
+ const healthHandler = (_req, res) => {
350
+ const state =
351
+ activeJobId.length > 0 ? "running" : lastError ? "error" : "idle";
352
+ res.json({
353
+ ok: true,
354
+ workerVersion: WORKER_VERSION,
355
+ host: WORKER_HOST,
356
+ mode,
357
+ state,
358
+ activeJobId: activeJobId || undefined,
359
+ lastError: lastError || undefined,
360
+ dispatchPath: "/dispatch-job",
361
+ chatPath: "/chat",
362
+ generatePath: "/generate",
363
+ tagsPath: "/tags",
364
+ ollamaBase,
365
+ });
366
+ };
367
+ app.get("/health", healthHandler);
368
+ app.get("/api/health", healthHandler);
369
+
370
+ const tagsHandler = async (_req, res) => {
371
+ try {
372
+ const r = await fetch(`${ollamaBase}/api/tags`, { method: "GET" });
373
+ const raw = await r.text();
374
+ if (!r.ok) {
375
+ res
376
+ .status(r.status)
377
+ .json({ error: `Ollama /api/tags failed ${r.status}`, raw });
378
+ return;
379
+ }
380
+ res.type("application/json").send(raw || "{}");
381
+ } catch (e) {
382
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
383
+ }
384
+ };
385
+ app.get("/tags", tagsHandler);
386
+ app.get("/api/tags", tagsHandler);
387
+
388
+ const dispatchJobHandler = async (req, res) => {
389
+ if (!requireDispatchToken(req, res)) return;
390
+ const body = req.body ?? {};
391
+ if (!body?.jobId || !body?.payload) {
392
+ res.status(400).json({ error: "Expected { jobId, payload }." });
393
+ return;
394
+ }
395
+ if (activeJobId) {
396
+ res.status(409).json({ error: "Worker busy.", activeJobId });
397
+ return;
398
+ }
399
+ const payload = body.payload;
400
+ const chatBody = {
401
+ model: payload.modelId,
402
+ messages: toOllamaChatMessages(payload.messages),
403
+ };
404
+ void runOllamaAndMaybeUpdateJob({
405
+ endpoint: "/api/chat",
406
+ requestBody: chatBody,
407
+ jobId: String(body.jobId),
408
+ extractResultText: (j) => j?.message?.content ?? "",
409
+ }).catch((e) => {
410
+ console.error("[dispatch-job] error:", e instanceof Error ? e.message : e);
411
+ });
412
+ res.status(202).json({ ok: true, accepted: true, jobId: body.jobId });
413
+ };
414
+ app.post("/dispatch-job", dispatchJobHandler);
415
+ app.post("/api/dispatch-job", dispatchJobHandler);
416
+
417
+ const chatHandler = async (req, res) => {
418
+ try {
419
+ const body = req.body ?? {};
420
+ const jobId = typeof body.jobId === "string" ? body.jobId : "";
421
+ if (jobId && !requireDispatchToken(req, res)) return;
422
+ const requestBody =
423
+ body.request && typeof body.request === "object" ? body.request : body;
424
+ const parsed = await runOllamaAndMaybeUpdateJob({
425
+ endpoint: "/api/chat",
426
+ requestBody,
427
+ jobId,
428
+ extractResultText: (j) => j?.message?.content ?? "",
429
+ });
430
+ res.json(parsed);
431
+ } catch (e) {
432
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
433
+ }
434
+ };
435
+ app.post("/chat", chatHandler);
436
+ app.post("/api/chat", chatHandler);
437
+
438
+ const generateHandler = async (req, res) => {
439
+ try {
440
+ const body = req.body ?? {};
441
+ const jobId = typeof body.jobId === "string" ? body.jobId : "";
442
+ if (jobId && !requireDispatchToken(req, res)) return;
443
+ const requestBody =
444
+ body.request && typeof body.request === "object" ? body.request : body;
445
+ const parsed = await runOllamaAndMaybeUpdateJob({
446
+ endpoint: "/api/generate",
447
+ requestBody,
448
+ jobId,
449
+ extractResultText: (j) => j?.response ?? "",
450
+ });
451
+ res.json(parsed);
452
+ } catch (e) {
453
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
454
+ }
455
+ };
456
+ app.post("/generate", generateHandler);
457
+ app.post("/api/generate", generateHandler);
458
+
459
+ webhookServer = app.listen(webhookPort, "127.0.0.1", () => {
460
+ console.log(
461
+ `[${ts()}] local worker API on http://127.0.0.1:${webhookPort} (/chat, /generate, /tags, /dispatch-job, /health)`
462
+ );
463
+ console.log(`[${ts()}] dispatch token is SHA256(worker key)`);
464
+ });
465
+ }
466
+
186
467
  async function main() {
187
468
  console.log(`[${ts()}] gonext-local-worker`);
188
469
  console.log(` API ${apiBase}`);
189
- console.log(` poll every ${pollMs}ms (idle)`);
470
+ console.log(` mode ${mode}`);
471
+ if (mode === "poll" || mode === "both") {
472
+ console.log(` poll every ${pollMs}ms (idle)`);
473
+ }
474
+ if (mode === "webhook" || mode === "both") {
475
+ console.log(` hook http://127.0.0.1:${webhookPort}/dispatch-job`);
476
+ }
190
477
  console.log(` stop Ctrl+C`);
478
+ await postHeartbeat({ state: "idle", currentJobId: "" });
191
479
 
192
480
  const loop = async () => {
193
481
  while (!shuttingDown) {
194
482
  try {
195
483
  await pollOnce();
484
+ await postHeartbeat({ state: "idle", currentJobId: "" });
196
485
  } catch (e) {
197
486
  console.error(`[${ts()}] poll error:`, e instanceof Error ? e.message : e);
487
+ await postHeartbeat({
488
+ state: "error",
489
+ currentJobId: "",
490
+ lastError: e instanceof Error ? e.message : String(e),
491
+ });
198
492
  }
199
493
  if (shuttingDown) break;
200
494
  await sleep(pollMs);
@@ -205,12 +499,22 @@ async function main() {
205
499
  if (shuttingDown) return;
206
500
  shuttingDown = true;
207
501
  console.log(`\n[${ts()}] shutting down…`);
502
+ if (webhookServer) {
503
+ webhookServer.close();
504
+ }
208
505
  process.exit(0);
209
506
  };
210
507
  process.on("SIGINT", stop);
211
508
  process.on("SIGTERM", stop);
212
509
 
213
- await loop();
510
+ if (mode === "webhook" || mode === "both") {
511
+ startWebhookServer();
512
+ }
513
+ if (mode === "poll" || mode === "both") {
514
+ await loop();
515
+ } else {
516
+ await new Promise(() => {});
517
+ }
214
518
  }
215
519
 
216
520
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "dotenv": "^16.4.5",
28
+ "express": "^4.21.0",
28
29
  "openai": "^4.77.0"
29
30
  },
30
31
  "engines": {