@tiens.nguyen/gonext-local-worker 1.0.0 → 1.0.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/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,50 @@ 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>/dispatch-job`
94
+
95
+ Local worker API endpoints on port 5001:
96
+
97
+ - `POST /chat` -> proxies to `http://localhost:11434/api/chat`
98
+ - `POST /generate` -> proxies to `http://localhost:11434/api/generate`
99
+ - `GET /tags` -> proxies to `http://localhost:11434/api/tags`
100
+ - `POST /dispatch-job` -> cloud dispatch entrypoint (token-protected)
101
+ - `GET /health`
102
+
64
103
  ## Run at login (macOS LaunchAgent)
65
104
 
66
105
  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,230 @@ 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
+ app.get("/health", (_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
+
368
+ app.get("/tags", async (_req, res) => {
369
+ try {
370
+ const r = await fetch(`${ollamaBase}/api/tags`, { method: "GET" });
371
+ const raw = await r.text();
372
+ if (!r.ok) {
373
+ res
374
+ .status(r.status)
375
+ .json({ error: `Ollama /api/tags failed ${r.status}`, raw });
376
+ return;
377
+ }
378
+ res.type("application/json").send(raw || "{}");
379
+ } catch (e) {
380
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
381
+ }
382
+ });
383
+
384
+ app.post("/dispatch-job", async (req, res) => {
385
+ if (!requireDispatchToken(req, res)) return;
386
+ const body = req.body ?? {};
387
+ if (!body?.jobId || !body?.payload) {
388
+ res.status(400).json({ error: "Expected { jobId, payload }." });
389
+ return;
390
+ }
391
+ if (activeJobId) {
392
+ res.status(409).json({ error: "Worker busy.", activeJobId });
393
+ return;
394
+ }
395
+ const payload = body.payload;
396
+ const chatBody = {
397
+ model: payload.modelId,
398
+ messages: toOllamaChatMessages(payload.messages),
399
+ };
400
+ void runOllamaAndMaybeUpdateJob({
401
+ endpoint: "/api/chat",
402
+ requestBody: chatBody,
403
+ jobId: String(body.jobId),
404
+ extractResultText: (j) => j?.message?.content ?? "",
405
+ }).catch((e) => {
406
+ console.error("[dispatch-job] error:", e instanceof Error ? e.message : e);
407
+ });
408
+ res.status(202).json({ ok: true, accepted: true, jobId: body.jobId });
409
+ });
410
+
411
+ app.post("/chat", async (req, res) => {
412
+ try {
413
+ const body = req.body ?? {};
414
+ const jobId = typeof body.jobId === "string" ? body.jobId : "";
415
+ if (jobId && !requireDispatchToken(req, res)) return;
416
+ const requestBody =
417
+ body.request && typeof body.request === "object" ? body.request : body;
418
+ const parsed = await runOllamaAndMaybeUpdateJob({
419
+ endpoint: "/api/chat",
420
+ requestBody,
421
+ jobId,
422
+ extractResultText: (j) => j?.message?.content ?? "",
423
+ });
424
+ res.json(parsed);
425
+ } catch (e) {
426
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
427
+ }
428
+ });
429
+
430
+ app.post("/generate", async (req, res) => {
431
+ try {
432
+ const body = req.body ?? {};
433
+ const jobId = typeof body.jobId === "string" ? body.jobId : "";
434
+ if (jobId && !requireDispatchToken(req, res)) return;
435
+ const requestBody =
436
+ body.request && typeof body.request === "object" ? body.request : body;
437
+ const parsed = await runOllamaAndMaybeUpdateJob({
438
+ endpoint: "/api/generate",
439
+ requestBody,
440
+ jobId,
441
+ extractResultText: (j) => j?.response ?? "",
442
+ });
443
+ res.json(parsed);
444
+ } catch (e) {
445
+ res.status(500).json({ error: e instanceof Error ? e.message : "error" });
446
+ }
447
+ });
448
+
449
+ webhookServer = app.listen(webhookPort, "127.0.0.1", () => {
450
+ console.log(
451
+ `[${ts()}] local worker API on http://127.0.0.1:${webhookPort} (/chat, /generate, /tags, /dispatch-job, /health)`
452
+ );
453
+ console.log(`[${ts()}] dispatch token is SHA256(worker key)`);
454
+ });
455
+ }
456
+
186
457
  async function main() {
187
458
  console.log(`[${ts()}] gonext-local-worker`);
188
459
  console.log(` API ${apiBase}`);
189
- console.log(` poll every ${pollMs}ms (idle)`);
460
+ console.log(` mode ${mode}`);
461
+ if (mode === "poll" || mode === "both") {
462
+ console.log(` poll every ${pollMs}ms (idle)`);
463
+ }
464
+ if (mode === "webhook" || mode === "both") {
465
+ console.log(` hook http://127.0.0.1:${webhookPort}/dispatch-job`);
466
+ }
190
467
  console.log(` stop Ctrl+C`);
468
+ await postHeartbeat({ state: "idle", currentJobId: "" });
191
469
 
192
470
  const loop = async () => {
193
471
  while (!shuttingDown) {
194
472
  try {
195
473
  await pollOnce();
474
+ await postHeartbeat({ state: "idle", currentJobId: "" });
196
475
  } catch (e) {
197
476
  console.error(`[${ts()}] poll error:`, e instanceof Error ? e.message : e);
477
+ await postHeartbeat({
478
+ state: "error",
479
+ currentJobId: "",
480
+ lastError: e instanceof Error ? e.message : String(e),
481
+ });
198
482
  }
199
483
  if (shuttingDown) break;
200
484
  await sleep(pollMs);
@@ -205,12 +489,22 @@ async function main() {
205
489
  if (shuttingDown) return;
206
490
  shuttingDown = true;
207
491
  console.log(`\n[${ts()}] shutting down…`);
492
+ if (webhookServer) {
493
+ webhookServer.close();
494
+ }
208
495
  process.exit(0);
209
496
  };
210
497
  process.on("SIGINT", stop);
211
498
  process.on("SIGTERM", stop);
212
499
 
213
- await loop();
500
+ if (mode === "webhook" || mode === "both") {
501
+ startWebhookServer();
502
+ }
503
+ if (mode === "poll" || mode === "both") {
504
+ await loop();
505
+ } else {
506
+ await new Promise(() => {});
507
+ }
214
508
  }
215
509
 
216
510
  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.1",
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": {