@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.js CHANGED
@@ -79,6 +79,12 @@ function toJsonColumn(value) {
79
79
  return JSON.stringify(value);
80
80
  }
81
81
 
82
+ // src/tasks/servicesRegistry.ts
83
+ import { randomUUID as randomUUID2 } from "crypto";
84
+ import { mkdir, readFile, writeFile } from "fs/promises";
85
+ import os from "os";
86
+ import path3 from "path";
87
+
82
88
  // src/tasks/taskUtils.ts
83
89
  import { randomUUID } from "crypto";
84
90
  function getDb(context) {
@@ -97,6 +103,12 @@ function queueToTableNames(queue) {
97
103
  historyTable: `${queue}_history`
98
104
  };
99
105
  }
106
+ function servicesRegistryTable(queue) {
107
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
108
+ throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
109
+ }
110
+ return `${queue}_services_registry`;
111
+ }
100
112
  async function ensureTaskTables(context, options = {}) {
101
113
  const queue = options.queue ?? "tasks";
102
114
  const recreate = options.recreate ?? false;
@@ -133,18 +145,6 @@ async function ensureTaskTables(context, options = {}) {
133
145
  t.index(["target", "task"], `${tasksTable}_target_task_idx`);
134
146
  });
135
147
  }
136
- const tasksHasOpid = await db.schema.hasColumn(tasksTable, "opid");
137
- if (!tasksHasOpid) {
138
- await db.schema.alterTable(tasksTable, (t) => {
139
- t.text("opid");
140
- });
141
- }
142
- const tasksHasPausedAt = await db.schema.hasColumn(tasksTable, "paused_at");
143
- if (!tasksHasPausedAt) {
144
- await db.schema.alterTable(tasksTable, (t) => {
145
- t.timestamp("paused_at").defaultTo(null);
146
- });
147
- }
148
148
  if (needsHistory) {
149
149
  await db.schema.createTable(historyTable, (t) => {
150
150
  t.uuid("id").notNullable();
@@ -167,10 +167,24 @@ async function ensureTaskTables(context, options = {}) {
167
167
  t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
168
168
  });
169
169
  }
170
- const historyHasOpid = await db.schema.hasColumn(historyTable, "opid");
171
- if (!historyHasOpid) {
172
- await db.schema.alterTable(historyTable, (t) => {
173
- t.text("opid");
170
+ const registryTable = servicesRegistryTable(queue);
171
+ const needsRegistry = !await db.tableExists(registryTable);
172
+ if (needsRegistry) {
173
+ await db.schema.createTable(registryTable, (t) => {
174
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
175
+ t.uuid("instance_id").notNullable().unique();
176
+ t.text("queue").notNullable();
177
+ t.text("service_group").notNullable();
178
+ t.text("service_name").notNullable();
179
+ t.text("target").notNullable();
180
+ t.text("hostname");
181
+ t.integer("pid");
182
+ t.json("metadata");
183
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
184
+ t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
185
+ t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
186
+ t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
187
+ t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
174
188
  });
175
189
  }
176
190
  }
@@ -197,9 +211,263 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
197
211
  });
198
212
  }
199
213
 
214
+ // src/tasks/servicesRegistry.ts
215
+ function getDb2(context) {
216
+ const db = context.db;
217
+ if (!db) {
218
+ throw new Error("Services registry requires context.db");
219
+ }
220
+ return db;
221
+ }
222
+ function parseMetadataColumn(value) {
223
+ if (!value) return {};
224
+ if (typeof value === "object" && !Array.isArray(value)) return value;
225
+ if (typeof value === "string") {
226
+ try {
227
+ const p = JSON.parse(value);
228
+ return p && typeof p === "object" && !Array.isArray(p) ? p : {};
229
+ } catch {
230
+ return {};
231
+ }
232
+ }
233
+ return {};
234
+ }
235
+ var DEFAULT_GROUP_MAX_INSTANCES = {
236
+ intake: 1,
237
+ harvest: 1,
238
+ loader: 0,
239
+ photos: 0,
240
+ photosprocessor: 0,
241
+ ingest: 0
242
+ };
243
+ function sanitizeNamePart(raw) {
244
+ const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
245
+ return s.slice(0, 80) || "runner";
246
+ }
247
+ function identityFilePath(identityDir, queue, serviceGroup) {
248
+ const safeQ = sanitizeNamePart(queue);
249
+ const safeG = sanitizeNamePart(serviceGroup);
250
+ return path3.join(identityDir, `${safeQ}_${safeG}.json`);
251
+ }
252
+ async function readIdentityFile(filePath) {
253
+ try {
254
+ const text = await readFile(filePath, "utf8");
255
+ const parsed = JSON.parse(text);
256
+ return parsed && typeof parsed === "object" ? parsed : {};
257
+ } catch {
258
+ return {};
259
+ }
260
+ }
261
+ async function writeIdentityFile(filePath, data) {
262
+ await mkdir(path3.dirname(filePath), { recursive: true });
263
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}
264
+ `, "utf8");
265
+ }
266
+ function resolveMaxInstances(serviceGroup, override) {
267
+ if (override !== void 0 && Number.isFinite(override)) {
268
+ return Math.max(0, Math.floor(Number(override)));
269
+ }
270
+ const g = serviceGroup.trim().toLowerCase();
271
+ return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
272
+ }
273
+ async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
274
+ const cutoff = new Date(Date.now() - staleMs);
275
+ let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
276
+ if (excludeInstanceId) {
277
+ q = q.whereNot("instance_id", excludeInstanceId);
278
+ }
279
+ const row = await q.count("id as count").first();
280
+ return Number(row?.count ?? 0);
281
+ }
282
+ function isUniqueViolation(error) {
283
+ const code = error?.code ?? error?.errno;
284
+ return code === "23505" || String(error?.message || "").includes("duplicate key");
285
+ }
286
+ async function registerInServicesRegistry(context, options) {
287
+ const db = getDb2(context);
288
+ const registryTable = servicesRegistryTable(options.queue);
289
+ const serviceGroup = options.serviceGroup.trim();
290
+ if (!serviceGroup) {
291
+ throw new Error("registerInServicesRegistry: serviceGroup is required");
292
+ }
293
+ const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
294
+ let identity = await readIdentityFile(identityPath);
295
+ let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : randomUUID2();
296
+ identity.instanceId = instanceId;
297
+ await writeIdentityFile(identityPath, identity);
298
+ const hostname = os.hostname();
299
+ const pid = typeof process.pid === "number" ? process.pid : null;
300
+ const meta = toJsonColumn(options.metadata ?? null);
301
+ const existing = await db(registryTable).where({ instance_id: instanceId }).first();
302
+ if (existing) {
303
+ await db(registryTable).where({ instance_id: instanceId }).update({
304
+ target: options.target,
305
+ hostname,
306
+ pid,
307
+ metadata: meta,
308
+ last_seen_at: db.fn.now()
309
+ });
310
+ const serviceName = String(existing.service_name);
311
+ identity.serviceName = serviceName;
312
+ await writeIdentityFile(identityPath, identity);
313
+ const reg = {
314
+ instanceId,
315
+ serviceName,
316
+ serviceGroup,
317
+ queue: options.queue,
318
+ target: options.target,
319
+ rowId: String(existing.id)
320
+ };
321
+ context.servicesRegistry = reg;
322
+ context.runnerHeartbeat = reg;
323
+ context.logger.info?.(
324
+ `[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
325
+ );
326
+ return {
327
+ instanceId,
328
+ serviceName,
329
+ serviceGroup,
330
+ queue: options.queue,
331
+ target: options.target,
332
+ rowId: String(existing.id),
333
+ registryTable
334
+ };
335
+ }
336
+ const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
337
+ const aliveOthers = await countAliveInGroup(
338
+ db,
339
+ registryTable,
340
+ options.queue,
341
+ serviceGroup,
342
+ options.staleMs,
343
+ instanceId
344
+ );
345
+ if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
346
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
347
+ if (options.enforceMaxInstances) {
348
+ throw new Error(msg);
349
+ }
350
+ context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
351
+ }
352
+ const explicitName = options.serviceName?.trim();
353
+ const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
354
+ const hostBase = sanitizeNamePart(hostname);
355
+ const groupBase = sanitizeNamePart(serviceGroup);
356
+ const baseCandidates = [];
357
+ if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
358
+ if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
359
+ baseCandidates.push(`${groupBase}-${hostBase}`);
360
+ baseCandidates.push(groupBase);
361
+ function* eachServiceNameCandidate(bases) {
362
+ const seen = /* @__PURE__ */ new Set();
363
+ for (const rawBase of bases) {
364
+ const base = sanitizeNamePart(rawBase);
365
+ if (!base) continue;
366
+ const seq = [base];
367
+ for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
368
+ for (const c of seq) {
369
+ if (seen.has(c)) continue;
370
+ seen.add(c);
371
+ yield c;
372
+ }
373
+ }
374
+ }
375
+ let inserted;
376
+ for (const candidate of eachServiceNameCandidate(baseCandidates)) {
377
+ try {
378
+ const rows = await db(registryTable).insert({
379
+ instance_id: instanceId,
380
+ queue: options.queue,
381
+ service_group: serviceGroup,
382
+ service_name: candidate,
383
+ target: options.target,
384
+ hostname,
385
+ pid,
386
+ metadata: meta,
387
+ last_seen_at: db.fn.now()
388
+ }).returning(["id", "service_name"]);
389
+ const row = Array.isArray(rows) ? rows[0] : rows;
390
+ if (row) {
391
+ inserted = { id: String(row.id), service_name: String(row.service_name) };
392
+ break;
393
+ }
394
+ } catch (error) {
395
+ if (!isUniqueViolation(error)) {
396
+ throw error;
397
+ }
398
+ }
399
+ }
400
+ if (!inserted) {
401
+ throw new Error(
402
+ `[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
403
+ );
404
+ }
405
+ identity.serviceName = inserted.service_name;
406
+ await writeIdentityFile(identityPath, identity);
407
+ const regNew = {
408
+ instanceId,
409
+ serviceName: inserted.service_name,
410
+ serviceGroup,
411
+ queue: options.queue,
412
+ target: options.target,
413
+ rowId: inserted.id
414
+ };
415
+ context.servicesRegistry = regNew;
416
+ context.runnerHeartbeat = regNew;
417
+ context.logger.info?.(
418
+ `[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
419
+ );
420
+ return {
421
+ instanceId,
422
+ serviceName: inserted.service_name,
423
+ serviceGroup,
424
+ queue: options.queue,
425
+ target: options.target,
426
+ rowId: inserted.id,
427
+ registryTable
428
+ };
429
+ }
430
+ async function touchServicesRegistry(context, registration) {
431
+ const db = getDb2(context);
432
+ const hostname = os.hostname();
433
+ const pid = typeof process.pid === "number" ? process.pid : null;
434
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
435
+ last_seen_at: db.fn.now(),
436
+ hostname,
437
+ pid
438
+ });
439
+ }
440
+ async function updateServicesRegistryMetadata(context, registration, patch) {
441
+ const db = getDb2(context);
442
+ const row = await db(registration.registryTable).where({ instance_id: registration.instanceId }).first();
443
+ const prev = parseMetadataColumn(row?.metadata);
444
+ const merged = { ...prev, ...patch };
445
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
446
+ metadata: toJsonColumn(merged),
447
+ last_seen_at: db.fn.now()
448
+ });
449
+ context.logger.info?.(`[services-registry] metadata updated for ${registration.serviceName}`);
450
+ }
451
+ async function unregisterServicesRegistry(context, registration) {
452
+ const db = getDb2(context);
453
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
454
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
455
+ }
456
+ async function listServicesRegistry(context, options = { queue: "tasks" }) {
457
+ const db = getDb2(context);
458
+ const staleMs = options.staleMs ?? 6e4;
459
+ const cutoff = new Date(Date.now() - staleMs);
460
+ const table = servicesRegistryTable(options.queue);
461
+ let q = db(table).where("last_seen_at", ">", cutoff).orderBy([{ column: "service_group", order: "asc" }, { column: "service_name", order: "asc" }]);
462
+ if (options.serviceGroup?.trim()) {
463
+ q = q.where({ service_group: options.serviceGroup.trim() });
464
+ }
465
+ return await q;
466
+ }
467
+
200
468
  // src/filedatabase/index.ts
201
469
  import fs3 from "fs";
202
- import path3 from "path";
470
+ import path4 from "path";
203
471
 
204
472
  // src/filedatabase/serializers.ts
205
473
  function detectDataType(data) {
@@ -346,7 +614,7 @@ var FileDatabase = class _FileDatabase {
346
614
  if (this.versioned && version) {
347
615
  parts.push(version);
348
616
  }
349
- return path3.resolve(...parts);
617
+ return path4.resolve(...parts);
350
618
  }
351
619
  /**
352
620
  * Set current version and version folder
@@ -383,7 +651,7 @@ var FileDatabase = class _FileDatabase {
383
651
  this.currentFileNumber = 0;
384
652
  const versions = await this.getVersions();
385
653
  while (versions.length > this.maxVersions) {
386
- const versionToDelete = path3.resolve(this.getDestinationPath(), versions.shift());
654
+ const versionToDelete = path4.resolve(this.getDestinationPath(), versions.shift());
387
655
  this.logger.silly?.(`[FileDatabase] Deleting old version: ${versionToDelete}`);
388
656
  await fs3.promises.rm(versionToDelete, { recursive: true, force: true });
389
657
  }
@@ -402,7 +670,7 @@ var FileDatabase = class _FileDatabase {
402
670
  await ensurePath(destPath);
403
671
  const items = await fs3.promises.readdir(destPath);
404
672
  const versions = items.filter((item) => {
405
- const itemPath = path3.join(destPath, item);
673
+ const itemPath = path4.join(destPath, item);
406
674
  const stat = fs3.statSync(itemPath);
407
675
  return stat.isDirectory() && isTimestampFolder(item);
408
676
  });
@@ -459,7 +727,7 @@ var FileDatabase = class _FileDatabase {
459
727
  const items = await fs3.promises.readdir(tablePath);
460
728
  if (items.includes("metadata.json")) {
461
729
  const metadata = JSON.parse(
462
- await fs3.promises.readFile(path3.join(tablePath, "metadata.json"), "utf8")
730
+ await fs3.promises.readFile(path4.join(tablePath, "metadata.json"), "utf8")
463
731
  );
464
732
  return {
465
733
  versioned: false,
@@ -468,13 +736,13 @@ var FileDatabase = class _FileDatabase {
468
736
  };
469
737
  }
470
738
  const versionFolders = items.filter((item) => {
471
- const itemPath = path3.join(tablePath, item);
739
+ const itemPath = path4.join(tablePath, item);
472
740
  const stat = fs3.statSync(itemPath);
473
741
  return stat.isDirectory() && isTimestampFolder(item);
474
742
  });
475
743
  if (versionFolders.length > 0) {
476
744
  const latestVersion = versionFolders.sort().pop();
477
- const versionMetadataPath = path3.join(tablePath, latestVersion, "metadata.json");
745
+ const versionMetadataPath = path4.join(tablePath, latestVersion, "metadata.json");
478
746
  return {
479
747
  versioned: true,
480
748
  hasMetadata: fs3.existsSync(versionMetadataPath),
@@ -495,7 +763,7 @@ var FileDatabase = class _FileDatabase {
495
763
  * Load metadata from JSON file
496
764
  */
497
765
  async loadMetadataJson(version) {
498
- const metadataFile = path3.join(this.getDestinationPath(), version, "metadata.json");
766
+ const metadataFile = path4.join(this.getDestinationPath(), version, "metadata.json");
499
767
  if (fs3.existsSync(metadataFile)) {
500
768
  try {
501
769
  const rawData = await fs3.promises.readFile(metadataFile, "utf8");
@@ -511,7 +779,7 @@ var FileDatabase = class _FileDatabase {
511
779
  * Reads all files to get accurate counts - used when synopsis calculation is needed
512
780
  */
513
781
  async figureMetadataFromVersionFiles(version) {
514
- const versionPath = path3.join(this.getDestinationPath(), version);
782
+ const versionPath = path4.join(this.getDestinationPath(), version);
515
783
  if (!fs3.existsSync(versionPath)) {
516
784
  return this.getDefaultMetadata();
517
785
  }
@@ -523,10 +791,10 @@ var FileDatabase = class _FileDatabase {
523
791
  let detectedDataType = null;
524
792
  for (let i = 0; i < files.length; i++) {
525
793
  const fileName = files[i];
526
- const filePath = path3.join(versionPath, fileName);
794
+ const filePath = path4.join(versionPath, fileName);
527
795
  try {
528
796
  const rawData = await fs3.promises.readFile(filePath, "utf8");
529
- const extension = path3.extname(fileName).toLowerCase();
797
+ const extension = path4.extname(fileName).toLowerCase();
530
798
  let dataType = "text";
531
799
  if (extension === ".json") {
532
800
  dataType = "json-array";
@@ -559,7 +827,7 @@ var FileDatabase = class _FileDatabase {
559
827
  * Much faster for large datasets with many files
560
828
  */
561
829
  async buildMetadataOptimized(version) {
562
- const versionPath = path3.join(this.getDestinationPath(), version);
830
+ const versionPath = path4.join(this.getDestinationPath(), version);
563
831
  if (!fs3.existsSync(versionPath)) {
564
832
  return this.getDefaultMetadata();
565
833
  }
@@ -575,7 +843,7 @@ var FileDatabase = class _FileDatabase {
575
843
  fileName
576
844
  }));
577
845
  const firstFile = metadata.files[0];
578
- const firstFilePath = path3.join(versionPath, firstFile.fileName);
846
+ const firstFilePath = path4.join(versionPath, firstFile.fileName);
579
847
  const firstFileRaw = await fs3.promises.readFile(firstFilePath, "utf8");
580
848
  let firstFileData;
581
849
  try {
@@ -592,7 +860,7 @@ var FileDatabase = class _FileDatabase {
592
860
  }
593
861
  if (files.length > 1) {
594
862
  const lastFile = metadata.files[metadata.files.length - 1];
595
- const lastFilePath = path3.join(versionPath, lastFile.fileName);
863
+ const lastFilePath = path4.join(versionPath, lastFile.fileName);
596
864
  const lastFileRaw = await fs3.promises.readFile(lastFilePath, "utf8");
597
865
  const lastFileData = deserializeData(lastFileRaw, metadata.dataType);
598
866
  lastFile.recordsCount = Array.isArray(lastFileData) ? lastFileData.length : 1;
@@ -643,9 +911,9 @@ var FileDatabase = class _FileDatabase {
643
911
  if (!this.currentVersion) {
644
912
  return;
645
913
  }
646
- metadataFile = path3.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
914
+ metadataFile = path4.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
647
915
  } else {
648
- metadataFile = path3.join(this.getDestinationPath(), "metadata.json");
916
+ metadataFile = path4.join(this.getDestinationPath(), "metadata.json");
649
917
  }
650
918
  await fs3.promises.writeFile(metadataFile, JSON.stringify(metadataToSave, null, 4), "utf8");
651
919
  }
@@ -708,7 +976,7 @@ var FileDatabase = class _FileDatabase {
708
976
  }
709
977
  }
710
978
  if (!Array.isArray(data) && !forceNewFile) {
711
- const lastFileExtension = path3.extname(lastFile.fileName);
979
+ const lastFileExtension = path4.extname(lastFile.fileName);
712
980
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
713
981
  if (lastFileExtension !== expectedExtension) {
714
982
  if (lastFileRecordsCount > 0) {
@@ -718,7 +986,7 @@ var FileDatabase = class _FileDatabase {
718
986
  }
719
987
  }
720
988
  } else if (!Array.isArray(data) && forceNewFile) {
721
- const lastFileExtension = path3.extname(lastFile.fileName);
989
+ const lastFileExtension = path4.extname(lastFile.fileName);
722
990
  const expectedExtension = `.${getFileExtension(incomingDataType)}`;
723
991
  if (lastFileExtension !== expectedExtension) {
724
992
  lastFile.fileName = `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(incomingDataType)}`;
@@ -808,7 +1076,7 @@ var FileDatabase = class _FileDatabase {
808
1076
  */
809
1077
  async safeWrite(filePath, data) {
810
1078
  const serializedData = serializeData(data);
811
- const dir = path3.dirname(filePath);
1079
+ const dir = path4.dirname(filePath);
812
1080
  const requiredBytes = Buffer.byteLength(serializedData, "utf8");
813
1081
  const freeBytes = getFreeDiskSpace(dir);
814
1082
  if (freeBytes !== null) {
@@ -853,7 +1121,7 @@ var FileDatabase = class _FileDatabase {
853
1121
  } else {
854
1122
  await ensurePath(this.getDestinationPath());
855
1123
  if (this.useMetadata === true) {
856
- const metadataPath = path3.join(this.getDestinationPath(), "metadata.json");
1124
+ const metadataPath = path4.join(this.getDestinationPath(), "metadata.json");
857
1125
  if (fs3.existsSync(metadataPath)) {
858
1126
  try {
859
1127
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -906,7 +1174,7 @@ var FileDatabase = class _FileDatabase {
906
1174
  }
907
1175
  if (this.useMetadata) {
908
1176
  const destPath = this.getDestinationPath();
909
- const metadataPath = path3.join(destPath, "metadata.json");
1177
+ const metadataPath = path4.join(destPath, "metadata.json");
910
1178
  if (fs3.existsSync(metadataPath)) {
911
1179
  try {
912
1180
  const rawData = await fs3.promises.readFile(metadataPath, "utf8");
@@ -942,7 +1210,7 @@ var FileDatabase = class _FileDatabase {
942
1210
  if (options.filename) {
943
1211
  const destPath2 = this.getDestinationPath();
944
1212
  await ensurePath(destPath2);
945
- const filePath = path3.join(destPath2, options.filename);
1213
+ const filePath = path4.join(destPath2, options.filename);
946
1214
  await this.safeWrite(filePath, data);
947
1215
  return;
948
1216
  }
@@ -991,11 +1259,11 @@ var FileDatabase = class _FileDatabase {
991
1259
  const forceNewFile = hasCustomMetadata && targetFileIndex === null;
992
1260
  let { dataToWrite, dataLeftOver, fileName } = this.figureOutDataAndFileToWrite(data, targetFileIndex, forceNewFile);
993
1261
  const destPath = this.getDestinationPath(this.currentVersion || void 0);
994
- await this.safeWrite(path3.join(destPath, fileName), dataToWrite);
1262
+ await this.safeWrite(path4.join(destPath, fileName), dataToWrite);
995
1263
  this.updateMetadata(dataToWrite, fileName, options.customMetadata);
996
1264
  while (dataLeftOver && dataLeftOver.length > 0 && targetFileIndex === null) {
997
1265
  const writeContext = this.figureOutDataAndFileToWrite(dataLeftOver);
998
- await this.safeWrite(path3.join(destPath, writeContext.fileName), writeContext.dataToWrite);
1266
+ await this.safeWrite(path4.join(destPath, writeContext.fileName), writeContext.dataToWrite);
999
1267
  this.updateMetadata(writeContext.dataToWrite, writeContext.fileName, options.customMetadata);
1000
1268
  dataLeftOver = writeContext.dataLeftOver;
1001
1269
  }
@@ -1011,7 +1279,7 @@ var FileDatabase = class _FileDatabase {
1011
1279
  const { version, nextPage = false, pageSize, filename } = options;
1012
1280
  if (filename) {
1013
1281
  const destPath = this.getDestinationPath(version);
1014
- const filePath = path3.join(destPath, filename);
1282
+ const filePath = path4.join(destPath, filename);
1015
1283
  try {
1016
1284
  const rawData = await fs3.promises.readFile(filePath, "utf8");
1017
1285
  return JSON.parse(rawData);
@@ -1023,7 +1291,7 @@ var FileDatabase = class _FileDatabase {
1023
1291
  const isNonPaginatedData = this.metadata.dataType === "text" || this.metadata.dataType === "xml" || this.metadata.dataType === "json-object";
1024
1292
  if (isNonPaginatedData) {
1025
1293
  const file = this.metadata.files[0];
1026
- const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1294
+ const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1027
1295
  try {
1028
1296
  const rawData = await fs3.promises.readFile(filePath, "utf8");
1029
1297
  return deserializeData(rawData, this.metadata.dataType);
@@ -1061,7 +1329,7 @@ var FileDatabase = class _FileDatabase {
1061
1329
  let cumulativeRecords = currentFileOffset;
1062
1330
  for (let i = currentFileIndex; i < this.metadata.files.length && recordsRead < effectivePageSize; i++) {
1063
1331
  const file = this.metadata.files[i];
1064
- const filePath = path3.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1332
+ const filePath = path4.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1065
1333
  try {
1066
1334
  const rawData = await fs3.promises.readFile(filePath, "utf8");
1067
1335
  const fileData = deserializeData(rawData, this.metadata.dataType);
@@ -1105,7 +1373,7 @@ var FileDatabase = class _FileDatabase {
1105
1373
  * Returns data file names (.json, .txt, .xml) excluding metadata.json.
1106
1374
  */
1107
1375
  async listFilenames() {
1108
- const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1376
+ const destPath = this.versioned && this.currentVersion ? path4.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1109
1377
  try {
1110
1378
  const entries = await fs3.promises.readdir(destPath, { withFileTypes: true });
1111
1379
  return entries.filter((e) => e.isFile() && e.name !== "metadata.json" && /\.(json|txt|xml)$/i.test(e.name)).map((e) => e.name);
@@ -1119,8 +1387,8 @@ var FileDatabase = class _FileDatabase {
1119
1387
  * Use with listFilenames() to manage individual files.
1120
1388
  */
1121
1389
  async removeFile(filename) {
1122
- const destPath = this.versioned && this.currentVersion ? path3.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1123
- const filePath = path3.join(destPath, filename);
1390
+ const destPath = this.versioned && this.currentVersion ? path4.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1391
+ const filePath = path4.join(destPath, filename);
1124
1392
  try {
1125
1393
  await fs3.promises.unlink(filePath);
1126
1394
  } catch (err) {
@@ -1146,7 +1414,7 @@ var FileDatabase = class _FileDatabase {
1146
1414
  this.metadata.files.splice(idx, 1);
1147
1415
  this.metadata.totalRecords = Math.max(0, (this.metadata.totalRecords || 0) - recordsCount);
1148
1416
  const destPath = this.getDestinationPath();
1149
- const filePath = path3.join(destPath, filename);
1417
+ const filePath = path4.join(destPath, filename);
1150
1418
  try {
1151
1419
  await fs3.promises.unlink(filePath);
1152
1420
  } catch (err) {
@@ -1202,7 +1470,7 @@ var FileDatabase = class _FileDatabase {
1202
1470
  });
1203
1471
  if (matches) {
1204
1472
  const destPath = this.getDestinationPath();
1205
- const filePath = path3.join(destPath, fileEntry.fileName);
1473
+ const filePath = path4.join(destPath, fileEntry.fileName);
1206
1474
  const fileData = await fs3.promises.readFile(filePath, "utf8");
1207
1475
  const data = deserializeData(fileData, metadata.dataType || "json-object");
1208
1476
  results.push({
@@ -1225,7 +1493,7 @@ var FileDatabase = class _FileDatabase {
1225
1493
  });
1226
1494
  if (matches) {
1227
1495
  const destPath = this.getDestinationPath(version);
1228
- const filePath = path3.join(destPath, fileEntry.fileName);
1496
+ const filePath = path4.join(destPath, fileEntry.fileName);
1229
1497
  const fileData = await fs3.promises.readFile(filePath, "utf8");
1230
1498
  const data = deserializeData(fileData, metadata.dataType || "json-object");
1231
1499
  results.push({
@@ -1585,7 +1853,7 @@ var TaskShellCommand = class extends TaskMaster {
1585
1853
  };
1586
1854
 
1587
1855
  // src/tasks/coreTasks/TaskSystemInfo.ts
1588
- import os from "os";
1856
+ import os2 from "os";
1589
1857
  import fs4 from "fs/promises";
1590
1858
  function toGb(valueBytes) {
1591
1859
  return `${(valueBytes / 1024 ** 3).toFixed(2)} GB`;
@@ -1607,10 +1875,10 @@ async function getDiskStats() {
1607
1875
  var TaskSystemInfo = class extends TaskMaster {
1608
1876
  async run() {
1609
1877
  try {
1610
- const totalMemory = os.totalmem();
1611
- const freeMemory = os.freemem();
1878
+ const totalMemory = os2.totalmem();
1879
+ const freeMemory = os2.freemem();
1612
1880
  const usedMemory = totalMemory - freeMemory;
1613
- const cpus = os.cpus();
1881
+ const cpus = os2.cpus();
1614
1882
  const cpuUtilization = cpus.map((cpu) => {
1615
1883
  const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0);
1616
1884
  const usage = (total - cpu.times.idle) / total * 100;
@@ -1636,10 +1904,10 @@ var TaskSystemInfo = class extends TaskMaster {
1636
1904
  utilization: cpuUtilization
1637
1905
  },
1638
1906
  runtime: {
1639
- platform: os.platform(),
1640
- arch: os.arch(),
1641
- uptimeSec: os.uptime(),
1642
- hostname: os.hostname()
1907
+ platform: os2.platform(),
1908
+ arch: os2.arch(),
1909
+ uptimeSec: os2.uptime(),
1910
+ hostname: os2.hostname()
1643
1911
  }
1644
1912
  };
1645
1913
  this.context.logger.info?.(`[TaskSystemInfo] collected system metrics (${this.task.id})`);
@@ -1872,7 +2140,7 @@ async function runNodeTaskScript(context, options) {
1872
2140
  // src/tasks/index.ts
1873
2141
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
1874
2142
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
1875
- function getDb2(context) {
2143
+ function getDb3(context) {
1876
2144
  const db = context.db;
1877
2145
  if (!db) {
1878
2146
  throw new Error("Tasks component requires context.db. Initialize DB first and attach to context.");
@@ -1916,7 +2184,7 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
1916
2184
  context.emitter.emit("stop", allowanceMs);
1917
2185
  }
1918
2186
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
1919
- const db = getDb2(context);
2187
+ const db = getDb3(context);
1920
2188
  const taskName = row.task;
1921
2189
  const TaskClass = registry.get(taskName);
1922
2190
  const { paused_at: _pausedAt, ...rowForHistory } = row;
@@ -2017,7 +2285,7 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
2017
2285
  return { stopRunnerRequested, stopAllowanceMs };
2018
2286
  }
2019
2287
  async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
2020
- const db = getDb2(context);
2288
+ const db = getDb3(context);
2021
2289
  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);
2022
2290
  if (taskNames && taskNames.length > 0) {
2023
2291
  query = query.whereIn("task", taskNames);
@@ -2063,25 +2331,76 @@ async function runTasksLoop(context, options) {
2063
2331
  let stopRequested = false;
2064
2332
  let stopAllowanceMs = 5e3;
2065
2333
  context.__tasksRunnerStop = false;
2066
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
2067
- if (!runningStopControlPromise) {
2068
- const claimedStopTask = await claimNextRunnableTask(
2069
- context,
2070
- tasksTable,
2071
- target,
2072
- registry,
2073
- 10,
2074
- ["stopRunner", "stop"]
2075
- );
2076
- if (claimedStopTask) {
2077
- runningStopControlPromise = executeClaimedTask(
2334
+ let registryReg = null;
2335
+ let registryInterval = null;
2336
+ const hbGroup = options.runnerServiceGroup?.trim();
2337
+ if (hbGroup) {
2338
+ const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
2339
+ const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
2340
+ const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
2341
+ const defaultMeta = {
2342
+ component: "tasks-runner",
2343
+ allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
2344
+ };
2345
+ registryReg = await registerInServicesRegistry(context, {
2346
+ queue,
2347
+ target,
2348
+ serviceGroup: hbGroup,
2349
+ serviceName: options.runnerServiceName,
2350
+ identityDir,
2351
+ staleMs,
2352
+ groupMaxInstances: options.runnerGroupMaxInstances,
2353
+ enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
2354
+ metadata: options.runnerMetadata ?? defaultMeta
2355
+ });
2356
+ registryInterval = setInterval(() => {
2357
+ void touchServicesRegistry(context, registryReg).catch((err) => {
2358
+ context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
2359
+ });
2360
+ }, hbIntervalMs);
2361
+ }
2362
+ try {
2363
+ while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
2364
+ if (!runningStopControlPromise) {
2365
+ const claimedStopTask = await claimNextRunnableTask(
2366
+ context,
2367
+ tasksTable,
2368
+ target,
2369
+ registry,
2370
+ 10,
2371
+ ["stopRunner", "stop"]
2372
+ );
2373
+ if (claimedStopTask) {
2374
+ runningStopControlPromise = executeClaimedTask(
2375
+ context,
2376
+ tasksTable,
2377
+ historyTable,
2378
+ claimedStopTask,
2379
+ registry,
2380
+ runningTaskInstances
2381
+ ).then(async (outcome) => {
2382
+ if (outcome.stopRunnerRequested && !stopRequested) {
2383
+ stopRequested = true;
2384
+ stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
2385
+ context.__tasksRunnerStop = true;
2386
+ await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2387
+ }
2388
+ }).finally(() => {
2389
+ runningStopControlPromise = null;
2390
+ });
2391
+ }
2392
+ }
2393
+ while (runningPromises.size < maxParallel) {
2394
+ const claimed = await claimNextRunnableTask(
2078
2395
  context,
2079
2396
  tasksTable,
2080
- historyTable,
2081
- claimedStopTask,
2397
+ target,
2082
2398
  registry,
2083
- runningTaskInstances
2084
- ).then(async (outcome) => {
2399
+ scanLimit,
2400
+ allowedTasks
2401
+ );
2402
+ if (!claimed) break;
2403
+ const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
2085
2404
  if (outcome.stopRunnerRequested && !stopRequested) {
2086
2405
  stopRequested = true;
2087
2406
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
@@ -2089,54 +2408,46 @@ async function runTasksLoop(context, options) {
2089
2408
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2090
2409
  }
2091
2410
  }).finally(() => {
2092
- runningStopControlPromise = null;
2411
+ runningPromises.delete(p);
2093
2412
  });
2413
+ runningPromises.add(p);
2414
+ }
2415
+ await sleepMs(pollMs);
2416
+ }
2417
+ if (context.isStop() && !stopRequested) {
2418
+ await signalRunningTasksStop(context, runningTaskInstances, 5e3);
2419
+ }
2420
+ if (runningPromises.size > 0) {
2421
+ if (stopRequested) {
2422
+ await Promise.race([
2423
+ Promise.allSettled(Array.from(runningPromises)),
2424
+ sleepMs(stopAllowanceMs).then(() => {
2425
+ context.logger.warn?.(
2426
+ `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
2427
+ );
2428
+ })
2429
+ ]);
2430
+ } else {
2431
+ await Promise.allSettled(Array.from(runningPromises));
2094
2432
  }
2095
2433
  }
2096
- while (runningPromises.size < maxParallel) {
2097
- const claimed = await claimNextRunnableTask(
2098
- context,
2099
- tasksTable,
2100
- target,
2101
- registry,
2102
- scanLimit,
2103
- allowedTasks
2104
- );
2105
- if (!claimed) break;
2106
- const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
2107
- if (outcome.stopRunnerRequested && !stopRequested) {
2108
- stopRequested = true;
2109
- stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
2110
- context.__tasksRunnerStop = true;
2111
- await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
2112
- }
2113
- }).finally(() => {
2114
- runningPromises.delete(p);
2115
- });
2116
- runningPromises.add(p);
2434
+ } finally {
2435
+ if (registryInterval) {
2436
+ clearInterval(registryInterval);
2437
+ registryInterval = null;
2117
2438
  }
2118
- await sleepMs(pollMs);
2119
- }
2120
- if (context.isStop() && !stopRequested) {
2121
- await signalRunningTasksStop(context, runningTaskInstances, 5e3);
2122
- }
2123
- if (runningPromises.size > 0) {
2124
- if (stopRequested) {
2125
- await Promise.race([
2126
- Promise.allSettled(Array.from(runningPromises)),
2127
- sleepMs(stopAllowanceMs).then(() => {
2128
- context.logger.warn?.(
2129
- `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
2130
- );
2131
- })
2132
- ]);
2133
- } else {
2134
- await Promise.allSettled(Array.from(runningPromises));
2439
+ if (registryReg) {
2440
+ await unregisterServicesRegistry(context, registryReg).catch((err) => {
2441
+ context.logger.warn?.(`[services-registry] unregister failed: ${err?.message ?? String(err)}`);
2442
+ });
2443
+ registryReg = null;
2444
+ delete context.servicesRegistry;
2445
+ delete context.runnerHeartbeat;
2135
2446
  }
2136
2447
  }
2137
2448
  }
2138
2449
  async function waitForTaskResult(context, taskId, options = {}) {
2139
- const db = getDb2(context);
2450
+ const db = getDb3(context);
2140
2451
  const queue = options.queue ?? "tasks";
2141
2452
  const timeoutMs = options.timeoutMs ?? 6e4;
2142
2453
  const pollMs = options.pollMs ?? 500;
@@ -2164,6 +2475,14 @@ var TasksManager = class _TasksManager {
2164
2475
  scanLimit;
2165
2476
  allowedTasks;
2166
2477
  registry;
2478
+ runnerServiceGroup;
2479
+ runnerServiceName;
2480
+ runnerIdentityDir;
2481
+ runnerHeartbeatIntervalMs;
2482
+ runnerHeartbeatStaleMs;
2483
+ runnerGroupMaxInstances;
2484
+ runnerEnforceMaxInstances;
2485
+ runnerMetadata;
2167
2486
  constructor(context, options = {}) {
2168
2487
  this.context = context;
2169
2488
  this.queue = options.queue ?? "tasks";
@@ -2174,6 +2493,14 @@ var TasksManager = class _TasksManager {
2174
2493
  this.scanLimit = options.scanLimit ?? 100;
2175
2494
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
2176
2495
  this.registry = normalizeRegistry(options.registry);
2496
+ this.runnerServiceGroup = options.runnerServiceGroup;
2497
+ this.runnerServiceName = options.runnerServiceName;
2498
+ this.runnerIdentityDir = options.runnerIdentityDir;
2499
+ this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
2500
+ this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
2501
+ this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
2502
+ this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
2503
+ this.runnerMetadata = options.runnerMetadata;
2177
2504
  }
2178
2505
  static init(context, options = {}) {
2179
2506
  const defs = {
@@ -2183,7 +2510,14 @@ var TasksManager = class _TasksManager {
2183
2510
  pollMs: "number default 1000",
2184
2511
  maxParallel: "number default 1",
2185
2512
  scanLimit: "number default 100",
2186
- allowedTasks: "string"
2513
+ allowedTasks: "string",
2514
+ runnerServiceGroup: "string",
2515
+ runnerServiceName: "string",
2516
+ runnerIdentityDir: "string default ./data/runner-identities",
2517
+ runnerHeartbeatIntervalMs: "number default 10000",
2518
+ runnerHeartbeatStaleMs: "number default 45000",
2519
+ runnerGroupMaxInstances: "number",
2520
+ runnerEnforceMaxInstances: "boolean default true"
2187
2521
  };
2188
2522
  const discovered = context.params.getAllForModule(defs);
2189
2523
  const resolved = {
@@ -2194,6 +2528,13 @@ var TasksManager = class _TasksManager {
2194
2528
  maxParallel: discovered.maxParallel,
2195
2529
  scanLimit: discovered.scanLimit,
2196
2530
  allowedTasks: discovered.allowedTasks,
2531
+ runnerServiceGroup: discovered.runnerServiceGroup,
2532
+ runnerServiceName: discovered.runnerServiceName,
2533
+ runnerIdentityDir: discovered.runnerIdentityDir,
2534
+ runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
2535
+ runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
2536
+ runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
2537
+ runnerEnforceMaxInstances: discovered.runnerEnforceMaxInstances,
2197
2538
  ...options
2198
2539
  };
2199
2540
  return new _TasksManager(context, resolved);
@@ -2212,7 +2553,15 @@ var TasksManager = class _TasksManager {
2212
2553
  maxParallel: options.maxParallel ?? this.maxParallel,
2213
2554
  scanLimit: options.scanLimit ?? this.scanLimit,
2214
2555
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
2215
- registry: options.registry ?? this.registry
2556
+ registry: options.registry ?? this.registry,
2557
+ runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
2558
+ runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
2559
+ runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
2560
+ runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
2561
+ runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
2562
+ runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
2563
+ runnerEnforceMaxInstances: options.runnerEnforceMaxInstances ?? this.runnerEnforceMaxInstances,
2564
+ runnerMetadata: options.runnerMetadata ?? this.runnerMetadata
2216
2565
  });
2217
2566
  }
2218
2567
  };
@@ -2231,9 +2580,20 @@ export {
2231
2580
  enqueueStopTask,
2232
2581
  enqueueTask,
2233
2582
  ensureTaskTables,
2583
+ listServicesRegistry as listAliveRunnerHeartbeats,
2584
+ listServicesRegistry,
2234
2585
  queueToTableNames,
2586
+ registerInServicesRegistry,
2587
+ registerInServicesRegistry as registerRunnerHeartbeat,
2235
2588
  runNodeTaskScript,
2236
2589
  runTasksLoop,
2590
+ servicesRegistryTable as runnerHeartbeatsTable,
2591
+ servicesRegistryTable,
2592
+ touchServicesRegistry as touchRunnerHeartbeat,
2593
+ touchServicesRegistry,
2594
+ unregisterServicesRegistry as unregisterRunnerHeartbeat,
2595
+ unregisterServicesRegistry,
2596
+ updateServicesRegistryMetadata,
2237
2597
  updateTaskProgress,
2238
2598
  waitForTaskResult
2239
2599
  };