@nmakarov/cli-toolkit 0.18.0 → 0.21.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/dist/tasks.cjs CHANGED
@@ -44,9 +44,20 @@ __export(tasks_exports, {
44
44
  enqueueStopTask: () => enqueueStopTask,
45
45
  enqueueTask: () => enqueueTask,
46
46
  ensureTaskTables: () => ensureTaskTables,
47
+ listAliveRunnerHeartbeats: () => listServicesRegistry,
48
+ listServicesRegistry: () => listServicesRegistry,
47
49
  queueToTableNames: () => queueToTableNames,
50
+ registerInServicesRegistry: () => registerInServicesRegistry,
51
+ registerRunnerHeartbeat: () => registerInServicesRegistry,
48
52
  runNodeTaskScript: () => runNodeTaskScript,
49
53
  runTasksLoop: () => runTasksLoop,
54
+ runnerHeartbeatsTable: () => servicesRegistryTable,
55
+ servicesRegistryTable: () => servicesRegistryTable,
56
+ touchRunnerHeartbeat: () => touchServicesRegistry,
57
+ touchServicesRegistry: () => touchServicesRegistry,
58
+ unregisterRunnerHeartbeat: () => unregisterServicesRegistry,
59
+ unregisterServicesRegistry: () => unregisterServicesRegistry,
60
+ updateServicesRegistryMetadata: () => updateServicesRegistryMetadata,
50
61
  updateTaskProgress: () => updateTaskProgress,
51
62
  waitForTaskResult: () => waitForTaskResult
52
63
  });
@@ -133,6 +144,12 @@ function toJsonColumn(value) {
133
144
  return JSON.stringify(value);
134
145
  }
135
146
 
147
+ // src/tasks/servicesRegistry.ts
148
+ var import_node_crypto2 = require("crypto");
149
+ var import_promises = require("fs/promises");
150
+ var import_node_os = __toESM(require("os"), 1);
151
+ var import_node_path = __toESM(require("path"), 1);
152
+
136
153
  // src/tasks/taskUtils.ts
137
154
  var import_node_crypto = require("crypto");
138
155
  function getDb(context) {
@@ -151,6 +168,12 @@ function queueToTableNames(queue) {
151
168
  historyTable: `${queue}_history`
152
169
  };
153
170
  }
171
+ function servicesRegistryTable(queue) {
172
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
173
+ throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
174
+ }
175
+ return `${queue}_services_registry`;
176
+ }
154
177
  async function ensureTaskTables(context, options = {}) {
155
178
  const queue = options.queue ?? "tasks";
156
179
  const recreate = options.recreate ?? false;
@@ -187,18 +210,6 @@ async function ensureTaskTables(context, options = {}) {
187
210
  t.index(["target", "task"], `${tasksTable}_target_task_idx`);
188
211
  });
189
212
  }
190
- const tasksHasOpid = await db.schema.hasColumn(tasksTable, "opid");
191
- if (!tasksHasOpid) {
192
- await db.schema.alterTable(tasksTable, (t) => {
193
- t.text("opid");
194
- });
195
- }
196
- const tasksHasPausedAt = await db.schema.hasColumn(tasksTable, "paused_at");
197
- if (!tasksHasPausedAt) {
198
- await db.schema.alterTable(tasksTable, (t) => {
199
- t.timestamp("paused_at").defaultTo(null);
200
- });
201
- }
202
213
  if (needsHistory) {
203
214
  await db.schema.createTable(historyTable, (t) => {
204
215
  t.uuid("id").notNullable();
@@ -221,10 +232,24 @@ async function ensureTaskTables(context, options = {}) {
221
232
  t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
222
233
  });
223
234
  }
224
- const historyHasOpid = await db.schema.hasColumn(historyTable, "opid");
225
- if (!historyHasOpid) {
226
- await db.schema.alterTable(historyTable, (t) => {
227
- t.text("opid");
235
+ const registryTable = servicesRegistryTable(queue);
236
+ const needsRegistry = !await db.tableExists(registryTable);
237
+ if (needsRegistry) {
238
+ await db.schema.createTable(registryTable, (t) => {
239
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
240
+ t.uuid("instance_id").notNullable().unique();
241
+ t.text("queue").notNullable();
242
+ t.text("service_group").notNullable();
243
+ t.text("service_name").notNullable();
244
+ t.text("target").notNullable();
245
+ t.text("hostname");
246
+ t.integer("pid");
247
+ t.json("metadata");
248
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
249
+ t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
250
+ t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
251
+ t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
252
+ t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
228
253
  });
229
254
  }
230
255
  }
@@ -251,6 +276,260 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
251
276
  });
252
277
  }
253
278
 
279
+ // src/tasks/servicesRegistry.ts
280
+ function getDb2(context) {
281
+ const db = context.db;
282
+ if (!db) {
283
+ throw new Error("Services registry requires context.db");
284
+ }
285
+ return db;
286
+ }
287
+ function parseMetadataColumn(value) {
288
+ if (!value) return {};
289
+ if (typeof value === "object" && !Array.isArray(value)) return value;
290
+ if (typeof value === "string") {
291
+ try {
292
+ const p = JSON.parse(value);
293
+ return p && typeof p === "object" && !Array.isArray(p) ? p : {};
294
+ } catch {
295
+ return {};
296
+ }
297
+ }
298
+ return {};
299
+ }
300
+ var DEFAULT_GROUP_MAX_INSTANCES = {
301
+ intake: 1,
302
+ harvest: 1,
303
+ loader: 0,
304
+ photos: 0,
305
+ photosprocessor: 0,
306
+ ingest: 0
307
+ };
308
+ function sanitizeNamePart(raw) {
309
+ const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
310
+ return s.slice(0, 80) || "runner";
311
+ }
312
+ function identityFilePath(identityDir, queue, serviceGroup) {
313
+ const safeQ = sanitizeNamePart(queue);
314
+ const safeG = sanitizeNamePart(serviceGroup);
315
+ return import_node_path.default.join(identityDir, `${safeQ}_${safeG}.json`);
316
+ }
317
+ async function readIdentityFile(filePath) {
318
+ try {
319
+ const text = await (0, import_promises.readFile)(filePath, "utf8");
320
+ const parsed = JSON.parse(text);
321
+ return parsed && typeof parsed === "object" ? parsed : {};
322
+ } catch {
323
+ return {};
324
+ }
325
+ }
326
+ async function writeIdentityFile(filePath, data) {
327
+ await (0, import_promises.mkdir)(import_node_path.default.dirname(filePath), { recursive: true });
328
+ await (0, import_promises.writeFile)(filePath, `${JSON.stringify(data, null, 2)}
329
+ `, "utf8");
330
+ }
331
+ function resolveMaxInstances(serviceGroup, override) {
332
+ if (override !== void 0 && Number.isFinite(override)) {
333
+ return Math.max(0, Math.floor(Number(override)));
334
+ }
335
+ const g = serviceGroup.trim().toLowerCase();
336
+ return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
337
+ }
338
+ async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
339
+ const cutoff = new Date(Date.now() - staleMs);
340
+ let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
341
+ if (excludeInstanceId) {
342
+ q = q.whereNot("instance_id", excludeInstanceId);
343
+ }
344
+ const row = await q.count("id as count").first();
345
+ return Number(row?.count ?? 0);
346
+ }
347
+ function isUniqueViolation(error) {
348
+ const code = error?.code ?? error?.errno;
349
+ return code === "23505" || String(error?.message || "").includes("duplicate key");
350
+ }
351
+ async function registerInServicesRegistry(context, options) {
352
+ const db = getDb2(context);
353
+ const registryTable = servicesRegistryTable(options.queue);
354
+ const serviceGroup = options.serviceGroup.trim();
355
+ if (!serviceGroup) {
356
+ throw new Error("registerInServicesRegistry: serviceGroup is required");
357
+ }
358
+ const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
359
+ let identity = await readIdentityFile(identityPath);
360
+ let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : (0, import_node_crypto2.randomUUID)();
361
+ identity.instanceId = instanceId;
362
+ await writeIdentityFile(identityPath, identity);
363
+ const hostname = import_node_os.default.hostname();
364
+ const pid = typeof process.pid === "number" ? process.pid : null;
365
+ const meta = toJsonColumn(options.metadata ?? null);
366
+ const existing = await db(registryTable).where({ instance_id: instanceId }).first();
367
+ if (existing) {
368
+ await db(registryTable).where({ instance_id: instanceId }).update({
369
+ target: options.target,
370
+ hostname,
371
+ pid,
372
+ metadata: meta,
373
+ last_seen_at: db.fn.now()
374
+ });
375
+ const serviceName = String(existing.service_name);
376
+ identity.serviceName = serviceName;
377
+ await writeIdentityFile(identityPath, identity);
378
+ const reg = {
379
+ instanceId,
380
+ serviceName,
381
+ serviceGroup,
382
+ queue: options.queue,
383
+ target: options.target,
384
+ rowId: String(existing.id)
385
+ };
386
+ context.servicesRegistry = reg;
387
+ context.runnerHeartbeat = reg;
388
+ context.logger.info?.(
389
+ `[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
390
+ );
391
+ return {
392
+ instanceId,
393
+ serviceName,
394
+ serviceGroup,
395
+ queue: options.queue,
396
+ target: options.target,
397
+ rowId: String(existing.id),
398
+ registryTable
399
+ };
400
+ }
401
+ const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
402
+ const aliveOthers = await countAliveInGroup(
403
+ db,
404
+ registryTable,
405
+ options.queue,
406
+ serviceGroup,
407
+ options.staleMs,
408
+ instanceId
409
+ );
410
+ if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
411
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
412
+ if (options.enforceMaxInstances) {
413
+ throw new Error(msg);
414
+ }
415
+ context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
416
+ }
417
+ const explicitName = options.serviceName?.trim();
418
+ const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
419
+ const hostBase = sanitizeNamePart(hostname);
420
+ const groupBase = sanitizeNamePart(serviceGroup);
421
+ const baseCandidates = [];
422
+ if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
423
+ if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
424
+ baseCandidates.push(`${groupBase}-${hostBase}`);
425
+ baseCandidates.push(groupBase);
426
+ function* eachServiceNameCandidate(bases) {
427
+ const seen = /* @__PURE__ */ new Set();
428
+ for (const rawBase of bases) {
429
+ const base = sanitizeNamePart(rawBase);
430
+ if (!base) continue;
431
+ const seq = [base];
432
+ for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
433
+ for (const c of seq) {
434
+ if (seen.has(c)) continue;
435
+ seen.add(c);
436
+ yield c;
437
+ }
438
+ }
439
+ }
440
+ let inserted;
441
+ for (const candidate of eachServiceNameCandidate(baseCandidates)) {
442
+ try {
443
+ const rows = await db(registryTable).insert({
444
+ instance_id: instanceId,
445
+ queue: options.queue,
446
+ service_group: serviceGroup,
447
+ service_name: candidate,
448
+ target: options.target,
449
+ hostname,
450
+ pid,
451
+ metadata: meta,
452
+ last_seen_at: db.fn.now()
453
+ }).returning(["id", "service_name"]);
454
+ const row = Array.isArray(rows) ? rows[0] : rows;
455
+ if (row) {
456
+ inserted = { id: String(row.id), service_name: String(row.service_name) };
457
+ break;
458
+ }
459
+ } catch (error) {
460
+ if (!isUniqueViolation(error)) {
461
+ throw error;
462
+ }
463
+ }
464
+ }
465
+ if (!inserted) {
466
+ throw new Error(
467
+ `[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
468
+ );
469
+ }
470
+ identity.serviceName = inserted.service_name;
471
+ await writeIdentityFile(identityPath, identity);
472
+ const regNew = {
473
+ instanceId,
474
+ serviceName: inserted.service_name,
475
+ serviceGroup,
476
+ queue: options.queue,
477
+ target: options.target,
478
+ rowId: inserted.id
479
+ };
480
+ context.servicesRegistry = regNew;
481
+ context.runnerHeartbeat = regNew;
482
+ context.logger.info?.(
483
+ `[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
484
+ );
485
+ return {
486
+ instanceId,
487
+ serviceName: inserted.service_name,
488
+ serviceGroup,
489
+ queue: options.queue,
490
+ target: options.target,
491
+ rowId: inserted.id,
492
+ registryTable
493
+ };
494
+ }
495
+ async function touchServicesRegistry(context, registration) {
496
+ const db = getDb2(context);
497
+ const hostname = import_node_os.default.hostname();
498
+ const pid = typeof process.pid === "number" ? process.pid : null;
499
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
500
+ last_seen_at: db.fn.now(),
501
+ hostname,
502
+ pid
503
+ });
504
+ }
505
+ async function updateServicesRegistryMetadata(context, registration, patch) {
506
+ const db = getDb2(context);
507
+ const row = await db(registration.registryTable).where({ instance_id: registration.instanceId }).first();
508
+ const prev = parseMetadataColumn(row?.metadata);
509
+ const merged = { ...prev, ...patch };
510
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
511
+ metadata: toJsonColumn(merged),
512
+ last_seen_at: db.fn.now()
513
+ });
514
+ context.logger.info?.(`[services-registry] metadata updated for ${registration.serviceName}`);
515
+ }
516
+ async function unregisterServicesRegistry(context, registration) {
517
+ const db = getDb2(context);
518
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
519
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
520
+ }
521
+ async function listServicesRegistry(context, options = { queue: "tasks" }) {
522
+ const db = getDb2(context);
523
+ const staleMs = options.staleMs ?? 6e4;
524
+ const cutoff = new Date(Date.now() - staleMs);
525
+ const table = servicesRegistryTable(options.queue);
526
+ let q = db(table).where("last_seen_at", ">", cutoff).orderBy([{ column: "service_group", order: "asc" }, { column: "service_name", order: "asc" }]);
527
+ if (options.serviceGroup?.trim()) {
528
+ q = q.where({ service_group: options.serviceGroup.trim() });
529
+ }
530
+ return await q;
531
+ }
532
+
254
533
  // src/filedatabase/index.ts
255
534
  var import_fs3 = __toESM(require("fs"), 1);
256
535
  var import_path3 = __toESM(require("path"), 1);
@@ -1639,8 +1918,8 @@ var TaskShellCommand = class extends TaskMaster {
1639
1918
  };
1640
1919
 
1641
1920
  // src/tasks/coreTasks/TaskSystemInfo.ts
1642
- var import_node_os = __toESM(require("os"), 1);
1643
- var import_promises = __toESM(require("fs/promises"), 1);
1921
+ var import_node_os2 = __toESM(require("os"), 1);
1922
+ var import_promises2 = __toESM(require("fs/promises"), 1);
1644
1923
  function toGb(valueBytes) {
1645
1924
  return `${(valueBytes / 1024 ** 3).toFixed(2)} GB`;
1646
1925
  }
@@ -1648,7 +1927,7 @@ function toMb(valueBytes) {
1648
1927
  return `${(valueBytes / 1024 ** 2).toFixed(2)} MB`;
1649
1928
  }
1650
1929
  async function getDiskStats() {
1651
- const stats = await import_promises.default.statfs("/");
1930
+ const stats = await import_promises2.default.statfs("/");
1652
1931
  const total = Number(stats.bsize) * Number(stats.blocks);
1653
1932
  const free = Number(stats.bsize) * Number(stats.bavail);
1654
1933
  const used = total - free;
@@ -1661,10 +1940,10 @@ async function getDiskStats() {
1661
1940
  var TaskSystemInfo = class extends TaskMaster {
1662
1941
  async run() {
1663
1942
  try {
1664
- const totalMemory = import_node_os.default.totalmem();
1665
- const freeMemory = import_node_os.default.freemem();
1943
+ const totalMemory = import_node_os2.default.totalmem();
1944
+ const freeMemory = import_node_os2.default.freemem();
1666
1945
  const usedMemory = totalMemory - freeMemory;
1667
- const cpus = import_node_os.default.cpus();
1946
+ const cpus = import_node_os2.default.cpus();
1668
1947
  const cpuUtilization = cpus.map((cpu) => {
1669
1948
  const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0);
1670
1949
  const usage = (total - cpu.times.idle) / total * 100;
@@ -1690,10 +1969,10 @@ var TaskSystemInfo = class extends TaskMaster {
1690
1969
  utilization: cpuUtilization
1691
1970
  },
1692
1971
  runtime: {
1693
- platform: import_node_os.default.platform(),
1694
- arch: import_node_os.default.arch(),
1695
- uptimeSec: import_node_os.default.uptime(),
1696
- hostname: import_node_os.default.hostname()
1972
+ platform: import_node_os2.default.platform(),
1973
+ arch: import_node_os2.default.arch(),
1974
+ uptimeSec: import_node_os2.default.uptime(),
1975
+ hostname: import_node_os2.default.hostname()
1697
1976
  }
1698
1977
  };
1699
1978
  this.context.logger.info?.(`[TaskSystemInfo] collected system metrics (${this.task.id})`);
@@ -1926,7 +2205,7 @@ async function runNodeTaskScript(context, options) {
1926
2205
  // src/tasks/index.ts
1927
2206
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
1928
2207
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
1929
- function getDb2(context) {
2208
+ function getDb3(context) {
1930
2209
  const db = context.db;
1931
2210
  if (!db) {
1932
2211
  throw new Error("Tasks component requires context.db. Initialize DB first and attach to context.");
@@ -1970,7 +2249,7 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
1970
2249
  context.emitter.emit("stop", allowanceMs);
1971
2250
  }
1972
2251
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
1973
- const db = getDb2(context);
2252
+ const db = getDb3(context);
1974
2253
  const taskName = row.task;
1975
2254
  const TaskClass = registry.get(taskName);
1976
2255
  const { paused_at: _pausedAt, ...rowForHistory } = row;
@@ -2071,7 +2350,7 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
2071
2350
  return { stopRunnerRequested, stopAllowanceMs };
2072
2351
  }
2073
2352
  async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
2074
- const db = getDb2(context);
2353
+ const db = getDb3(context);
2075
2354
  let query = db(tasksTable).whereNull("started_at").whereNull("paused_at").where({ target }).orderByRaw("CASE WHEN past_due IS NULL THEN 1 ELSE 0 END ASC").orderBy([{ column: "priority", order: "desc" }]).orderByRaw("CASE WHEN completed_at IS NULL THEN 0 ELSE 1 END ASC").orderBy([{ column: "completed_at", order: "asc" }, { column: "created_at", order: "asc" }]).limit(scanLimit);
2076
2355
  if (taskNames && taskNames.length > 0) {
2077
2356
  query = query.whereIn("task", taskNames);
@@ -2117,25 +2396,76 @@ async function runTasksLoop(context, options) {
2117
2396
  let stopRequested = false;
2118
2397
  let stopAllowanceMs = 5e3;
2119
2398
  context.__tasksRunnerStop = false;
2120
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
2121
- if (!runningStopControlPromise) {
2122
- const claimedStopTask = await claimNextRunnableTask(
2123
- context,
2124
- tasksTable,
2125
- target,
2126
- registry,
2127
- 10,
2128
- ["stopRunner", "stop"]
2129
- );
2130
- if (claimedStopTask) {
2131
- runningStopControlPromise = executeClaimedTask(
2399
+ let registryReg = null;
2400
+ let registryInterval = null;
2401
+ const hbGroup = options.runnerServiceGroup?.trim();
2402
+ if (hbGroup) {
2403
+ const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
2404
+ const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
2405
+ const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
2406
+ const defaultMeta = {
2407
+ component: "tasks-runner",
2408
+ allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
2409
+ };
2410
+ registryReg = await registerInServicesRegistry(context, {
2411
+ queue,
2412
+ target,
2413
+ serviceGroup: hbGroup,
2414
+ serviceName: options.runnerServiceName,
2415
+ identityDir,
2416
+ staleMs,
2417
+ groupMaxInstances: options.runnerGroupMaxInstances,
2418
+ enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
2419
+ metadata: options.runnerMetadata ?? defaultMeta
2420
+ });
2421
+ registryInterval = setInterval(() => {
2422
+ void touchServicesRegistry(context, registryReg).catch((err) => {
2423
+ context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
2424
+ });
2425
+ }, hbIntervalMs);
2426
+ }
2427
+ try {
2428
+ while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
2429
+ if (!runningStopControlPromise) {
2430
+ const claimedStopTask = await claimNextRunnableTask(
2431
+ context,
2432
+ tasksTable,
2433
+ target,
2434
+ registry,
2435
+ 10,
2436
+ ["stopRunner", "stop"]
2437
+ );
2438
+ if (claimedStopTask) {
2439
+ runningStopControlPromise = executeClaimedTask(
2440
+ context,
2441
+ tasksTable,
2442
+ historyTable,
2443
+ claimedStopTask,
2444
+ registry,
2445
+ runningTaskInstances
2446
+ ).then(async (outcome) => {
2447
+ if (outcome.stopRunnerRequested && !stopRequested) {
2448
+ stopRequested = true;
2449
+ stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
2450
+ context.__tasksRunnerStop = true;
2451
+ await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2452
+ }
2453
+ }).finally(() => {
2454
+ runningStopControlPromise = null;
2455
+ });
2456
+ }
2457
+ }
2458
+ while (runningPromises.size < maxParallel) {
2459
+ const claimed = await claimNextRunnableTask(
2132
2460
  context,
2133
2461
  tasksTable,
2134
- historyTable,
2135
- claimedStopTask,
2462
+ target,
2136
2463
  registry,
2137
- runningTaskInstances
2138
- ).then(async (outcome) => {
2464
+ scanLimit,
2465
+ allowedTasks
2466
+ );
2467
+ if (!claimed) break;
2468
+ const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
2139
2469
  if (outcome.stopRunnerRequested && !stopRequested) {
2140
2470
  stopRequested = true;
2141
2471
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
@@ -2143,54 +2473,46 @@ async function runTasksLoop(context, options) {
2143
2473
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2144
2474
  }
2145
2475
  }).finally(() => {
2146
- runningStopControlPromise = null;
2476
+ runningPromises.delete(p);
2147
2477
  });
2478
+ runningPromises.add(p);
2479
+ }
2480
+ await sleepMs(pollMs);
2481
+ }
2482
+ if (context.isStop() && !stopRequested) {
2483
+ await signalRunningTasksStop(context, runningTaskInstances, 5e3);
2484
+ }
2485
+ if (runningPromises.size > 0) {
2486
+ if (stopRequested) {
2487
+ await Promise.race([
2488
+ Promise.allSettled(Array.from(runningPromises)),
2489
+ sleepMs(stopAllowanceMs).then(() => {
2490
+ context.logger.warn?.(
2491
+ `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
2492
+ );
2493
+ })
2494
+ ]);
2495
+ } else {
2496
+ await Promise.allSettled(Array.from(runningPromises));
2148
2497
  }
2149
2498
  }
2150
- while (runningPromises.size < maxParallel) {
2151
- const claimed = await claimNextRunnableTask(
2152
- context,
2153
- tasksTable,
2154
- target,
2155
- registry,
2156
- scanLimit,
2157
- allowedTasks
2158
- );
2159
- if (!claimed) break;
2160
- const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
2161
- if (outcome.stopRunnerRequested && !stopRequested) {
2162
- stopRequested = true;
2163
- stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
2164
- context.__tasksRunnerStop = true;
2165
- await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2166
- }
2167
- }).finally(() => {
2168
- runningPromises.delete(p);
2169
- });
2170
- runningPromises.add(p);
2499
+ } finally {
2500
+ if (registryInterval) {
2501
+ clearInterval(registryInterval);
2502
+ registryInterval = null;
2171
2503
  }
2172
- await sleepMs(pollMs);
2173
- }
2174
- if (context.isStop() && !stopRequested) {
2175
- await signalRunningTasksStop(context, runningTaskInstances, 5e3);
2176
- }
2177
- if (runningPromises.size > 0) {
2178
- if (stopRequested) {
2179
- await Promise.race([
2180
- Promise.allSettled(Array.from(runningPromises)),
2181
- sleepMs(stopAllowanceMs).then(() => {
2182
- context.logger.warn?.(
2183
- `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
2184
- );
2185
- })
2186
- ]);
2187
- } else {
2188
- await Promise.allSettled(Array.from(runningPromises));
2504
+ if (registryReg) {
2505
+ await unregisterServicesRegistry(context, registryReg).catch((err) => {
2506
+ context.logger.warn?.(`[services-registry] unregister failed: ${err?.message ?? String(err)}`);
2507
+ });
2508
+ registryReg = null;
2509
+ delete context.servicesRegistry;
2510
+ delete context.runnerHeartbeat;
2189
2511
  }
2190
2512
  }
2191
2513
  }
2192
2514
  async function waitForTaskResult(context, taskId, options = {}) {
2193
- const db = getDb2(context);
2515
+ const db = getDb3(context);
2194
2516
  const queue = options.queue ?? "tasks";
2195
2517
  const timeoutMs = options.timeoutMs ?? 6e4;
2196
2518
  const pollMs = options.pollMs ?? 500;
@@ -2218,6 +2540,14 @@ var TasksManager = class _TasksManager {
2218
2540
  scanLimit;
2219
2541
  allowedTasks;
2220
2542
  registry;
2543
+ runnerServiceGroup;
2544
+ runnerServiceName;
2545
+ runnerIdentityDir;
2546
+ runnerHeartbeatIntervalMs;
2547
+ runnerHeartbeatStaleMs;
2548
+ runnerGroupMaxInstances;
2549
+ runnerEnforceMaxInstances;
2550
+ runnerMetadata;
2221
2551
  constructor(context, options = {}) {
2222
2552
  this.context = context;
2223
2553
  this.queue = options.queue ?? "tasks";
@@ -2228,6 +2558,14 @@ var TasksManager = class _TasksManager {
2228
2558
  this.scanLimit = options.scanLimit ?? 100;
2229
2559
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
2230
2560
  this.registry = normalizeRegistry(options.registry);
2561
+ this.runnerServiceGroup = options.runnerServiceGroup;
2562
+ this.runnerServiceName = options.runnerServiceName;
2563
+ this.runnerIdentityDir = options.runnerIdentityDir;
2564
+ this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
2565
+ this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
2566
+ this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
2567
+ this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
2568
+ this.runnerMetadata = options.runnerMetadata;
2231
2569
  }
2232
2570
  static init(context, options = {}) {
2233
2571
  const defs = {
@@ -2237,7 +2575,14 @@ var TasksManager = class _TasksManager {
2237
2575
  pollMs: "number default 1000",
2238
2576
  maxParallel: "number default 1",
2239
2577
  scanLimit: "number default 100",
2240
- allowedTasks: "string"
2578
+ allowedTasks: "string",
2579
+ runnerServiceGroup: "string",
2580
+ runnerServiceName: "string",
2581
+ runnerIdentityDir: "string default ./data/runner-identities",
2582
+ runnerHeartbeatIntervalMs: "number default 10000",
2583
+ runnerHeartbeatStaleMs: "number default 45000",
2584
+ runnerGroupMaxInstances: "number",
2585
+ runnerEnforceMaxInstances: "boolean default true"
2241
2586
  };
2242
2587
  const discovered = context.params.getAllForModule(defs);
2243
2588
  const resolved = {
@@ -2248,6 +2593,13 @@ var TasksManager = class _TasksManager {
2248
2593
  maxParallel: discovered.maxParallel,
2249
2594
  scanLimit: discovered.scanLimit,
2250
2595
  allowedTasks: discovered.allowedTasks,
2596
+ runnerServiceGroup: discovered.runnerServiceGroup,
2597
+ runnerServiceName: discovered.runnerServiceName,
2598
+ runnerIdentityDir: discovered.runnerIdentityDir,
2599
+ runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
2600
+ runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
2601
+ runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
2602
+ runnerEnforceMaxInstances: discovered.runnerEnforceMaxInstances,
2251
2603
  ...options
2252
2604
  };
2253
2605
  return new _TasksManager(context, resolved);
@@ -2266,7 +2618,15 @@ var TasksManager = class _TasksManager {
2266
2618
  maxParallel: options.maxParallel ?? this.maxParallel,
2267
2619
  scanLimit: options.scanLimit ?? this.scanLimit,
2268
2620
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
2269
- registry: options.registry ?? this.registry
2621
+ registry: options.registry ?? this.registry,
2622
+ runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
2623
+ runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
2624
+ runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
2625
+ runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
2626
+ runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
2627
+ runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
2628
+ runnerEnforceMaxInstances: options.runnerEnforceMaxInstances ?? this.runnerEnforceMaxInstances,
2629
+ runnerMetadata: options.runnerMetadata ?? this.runnerMetadata
2270
2630
  });
2271
2631
  }
2272
2632
  };
@@ -2286,9 +2646,20 @@ var TasksManager = class _TasksManager {
2286
2646
  enqueueStopTask,
2287
2647
  enqueueTask,
2288
2648
  ensureTaskTables,
2649
+ listAliveRunnerHeartbeats,
2650
+ listServicesRegistry,
2289
2651
  queueToTableNames,
2652
+ registerInServicesRegistry,
2653
+ registerRunnerHeartbeat,
2290
2654
  runNodeTaskScript,
2291
2655
  runTasksLoop,
2656
+ runnerHeartbeatsTable,
2657
+ servicesRegistryTable,
2658
+ touchRunnerHeartbeat,
2659
+ touchServicesRegistry,
2660
+ unregisterRunnerHeartbeat,
2661
+ unregisterServicesRegistry,
2662
+ updateServicesRegistryMetadata,
2292
2663
  updateTaskProgress,
2293
2664
  waitForTaskResult
2294
2665
  });