@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/index.cjs CHANGED
@@ -932,11 +932,11 @@ function buildBreadcrumb(parts) {
932
932
  if (parts.length === 1) return parts[0];
933
933
  return parts.slice(1).map((part) => `\u2190 ${part}`).join(" ");
934
934
  }
935
- function buildDetailBreadcrumb(path4, suffix = "") {
936
- if (path4.length <= 1) {
937
- return suffix ? `\u2190 ${suffix}` : path4[0] || "";
935
+ function buildDetailBreadcrumb(path5, suffix = "") {
936
+ if (path5.length <= 1) {
937
+ return suffix ? `\u2190 ${suffix}` : path5[0] || "";
938
938
  }
939
- const breadcrumb = buildBreadcrumb(path4);
939
+ const breadcrumb = buildBreadcrumb(path5);
940
940
  return suffix ? `${breadcrumb} ${suffix}` : breadcrumb;
941
941
  }
942
942
  var init_utils = __esm({
@@ -1135,13 +1135,19 @@ __export(src_exports, {
1135
1135
  h: () => import_react5.createElement,
1136
1136
  joiEdateType: () => joiEdateType,
1137
1137
  joiStringArrayType: () => joiStringArrayType,
1138
+ listAliveRunnerHeartbeats: () => listServicesRegistry,
1139
+ listServicesRegistry: () => listServicesRegistry,
1138
1140
  listSources: () => listSources,
1139
1141
  listTables: () => listTables,
1140
1142
  load: () => load,
1141
1143
  organizeFooterMessages: () => organizeFooterMessages,
1142
1144
  queueToTableNames: () => queueToTableNames,
1145
+ registerInServicesRegistry: () => registerInServicesRegistry,
1146
+ registerRunnerHeartbeat: () => registerInServicesRegistry,
1143
1147
  runNodeTaskScript: () => runNodeTaskScript,
1144
1148
  runTasksLoop: () => runTasksLoop,
1149
+ runnerHeartbeatsTable: () => servicesRegistryTable,
1150
+ servicesRegistryTable: () => servicesRegistryTable,
1145
1151
  setupContext: () => setupContext,
1146
1152
  showListScreen: () => showListScreen,
1147
1153
  showMenuScreen: () => showMenuScreen,
@@ -1149,6 +1155,11 @@ __export(src_exports, {
1149
1155
  showMultiColumnListWithPreviewScreen: () => showMultiColumnListWithPreviewScreen,
1150
1156
  showScreen: () => showScreen,
1151
1157
  showWordGridScreen: () => showWordGridScreen,
1158
+ touchRunnerHeartbeat: () => touchServicesRegistry,
1159
+ touchServicesRegistry: () => touchServicesRegistry,
1160
+ unregisterRunnerHeartbeat: () => unregisterServicesRegistry,
1161
+ unregisterServicesRegistry: () => unregisterServicesRegistry,
1162
+ updateServicesRegistryMetadata: () => updateServicesRegistryMetadata,
1152
1163
  updateTaskProgress: () => updateTaskProgress,
1153
1164
  useCallback: () => import_react5.useCallback,
1154
1165
  useEffect: () => import_react5.useEffect,
@@ -1981,7 +1992,7 @@ var Params = class _Params {
1981
1992
  throw new ParamError(`default value "${defValObj.value}" type mismatch`);
1982
1993
  }
1983
1994
  type = type.default(defValObj.value);
1984
- } else if (str.match(/required/)) {
1995
+ } else if (str.match(/\s*required\s*/)) {
1985
1996
  type = type.required();
1986
1997
  } else {
1987
1998
  type = type.optional();
@@ -2057,6 +2068,8 @@ var Params = class _Params {
2057
2068
  /**
2058
2069
  * Get all parameters from definitions (main script).
2059
2070
  * Same as getAllForModule("script", defs). Processes left-to-right for cross-parameter references.
2071
+ * Libraries should use {@link getAllForModule} with an explicit module name (or {@link runWithModule}
2072
+ * around {@link get}) so --showUsedParams groups usage correctly.
2060
2073
  */
2061
2074
  getAll(defs) {
2062
2075
  return this.getAllForModule("script", defs);
@@ -2093,6 +2106,19 @@ var Params = class _Params {
2093
2106
  this._currentModule = prev;
2094
2107
  }
2095
2108
  }
2109
+ /**
2110
+ * Run a callback with {@link _currentModule} set so single {@link get} calls are tracked
2111
+ * under the same module (for --showUsedParams / getFiguredByModule).
2112
+ */
2113
+ runWithModule(moduleName, fn) {
2114
+ const prev = this._currentModule;
2115
+ this._currentModule = moduleName;
2116
+ try {
2117
+ return fn();
2118
+ } finally {
2119
+ this._currentModule = prev;
2120
+ }
2121
+ }
2096
2122
  /**
2097
2123
  * Infer module name from call stack: first caller outside params/index gives path like .../src/<moduleName>/...
2098
2124
  */
@@ -2106,9 +2132,9 @@ var Params = class _Params {
2106
2132
  if (!parenMatch) continue;
2107
2133
  const parts = parenMatch[1].split(":");
2108
2134
  if (parts.length < 3) continue;
2109
- const path4 = parts.slice(0, -2).join(":").replace(/^file:\/\//, "");
2110
- if (!path4 || path4.includes(paramsIndexPath)) continue;
2111
- const srcMatch = path4.match(/[/\\]src[/\\]([^/\\]+)(?:[/\\]|$)/);
2135
+ const path5 = parts.slice(0, -2).join(":").replace(/^file:\/\//, "");
2136
+ if (!path5 || path5.includes(paramsIndexPath)) continue;
2137
+ const srcMatch = path5.match(/[/\\]src[/\\]([^/\\]+)(?:[/\\]|$)/);
2112
2138
  if (srcMatch) return srcMatch[1];
2113
2139
  }
2114
2140
  return "script";
@@ -3710,7 +3736,7 @@ async function dbFindAndConnect(context, dbNameOrConnectionString) {
3710
3736
  dbConnectionString: "string",
3711
3737
  dbProfile: "boolean default false"
3712
3738
  };
3713
- const paramsConfig = context.params.getAllForModule(defs);
3739
+ const paramsConfig = context.params.getAll(defs);
3714
3740
  dbName = paramsConfig.dbName;
3715
3741
  dbConnectionString = paramsConfig.dbConnectionString;
3716
3742
  dbProfile = paramsConfig.dbProfile;
@@ -4096,6 +4122,12 @@ function toJsonColumn(value) {
4096
4122
  return JSON.stringify(value);
4097
4123
  }
4098
4124
 
4125
+ // src/tasks/servicesRegistry.ts
4126
+ var import_node_crypto2 = require("crypto");
4127
+ var import_promises = require("fs/promises");
4128
+ var import_node_os = __toESM(require("os"), 1);
4129
+ var import_node_path = __toESM(require("path"), 1);
4130
+
4099
4131
  // src/tasks/taskUtils.ts
4100
4132
  var import_node_crypto = require("crypto");
4101
4133
  function getDb(context) {
@@ -4114,6 +4146,12 @@ function queueToTableNames(queue) {
4114
4146
  historyTable: `${queue}_history`
4115
4147
  };
4116
4148
  }
4149
+ function servicesRegistryTable(queue) {
4150
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
4151
+ throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
4152
+ }
4153
+ return `${queue}_services_registry`;
4154
+ }
4117
4155
  async function ensureTaskTables(context, options = {}) {
4118
4156
  const queue = options.queue ?? "tasks";
4119
4157
  const recreate = options.recreate ?? false;
@@ -4150,18 +4188,6 @@ async function ensureTaskTables(context, options = {}) {
4150
4188
  t.index(["target", "task"], `${tasksTable}_target_task_idx`);
4151
4189
  });
4152
4190
  }
4153
- const tasksHasOpid = await db.schema.hasColumn(tasksTable, "opid");
4154
- if (!tasksHasOpid) {
4155
- await db.schema.alterTable(tasksTable, (t) => {
4156
- t.text("opid");
4157
- });
4158
- }
4159
- const tasksHasPausedAt = await db.schema.hasColumn(tasksTable, "paused_at");
4160
- if (!tasksHasPausedAt) {
4161
- await db.schema.alterTable(tasksTable, (t) => {
4162
- t.timestamp("paused_at").defaultTo(null);
4163
- });
4164
- }
4165
4191
  if (needsHistory) {
4166
4192
  await db.schema.createTable(historyTable, (t) => {
4167
4193
  t.uuid("id").notNullable();
@@ -4184,10 +4210,24 @@ async function ensureTaskTables(context, options = {}) {
4184
4210
  t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
4185
4211
  });
4186
4212
  }
4187
- const historyHasOpid = await db.schema.hasColumn(historyTable, "opid");
4188
- if (!historyHasOpid) {
4189
- await db.schema.alterTable(historyTable, (t) => {
4190
- t.text("opid");
4213
+ const registryTable = servicesRegistryTable(queue);
4214
+ const needsRegistry = !await db.tableExists(registryTable);
4215
+ if (needsRegistry) {
4216
+ await db.schema.createTable(registryTable, (t) => {
4217
+ t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
4218
+ t.uuid("instance_id").notNullable().unique();
4219
+ t.text("queue").notNullable();
4220
+ t.text("service_group").notNullable();
4221
+ t.text("service_name").notNullable();
4222
+ t.text("target").notNullable();
4223
+ t.text("hostname");
4224
+ t.integer("pid");
4225
+ t.json("metadata");
4226
+ t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
4227
+ t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
4228
+ t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
4229
+ t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
4230
+ t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
4191
4231
  });
4192
4232
  }
4193
4233
  }
@@ -4214,6 +4254,260 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
4214
4254
  });
4215
4255
  }
4216
4256
 
4257
+ // src/tasks/servicesRegistry.ts
4258
+ function getDb2(context) {
4259
+ const db = context.db;
4260
+ if (!db) {
4261
+ throw new Error("Services registry requires context.db");
4262
+ }
4263
+ return db;
4264
+ }
4265
+ function parseMetadataColumn(value) {
4266
+ if (!value) return {};
4267
+ if (typeof value === "object" && !Array.isArray(value)) return value;
4268
+ if (typeof value === "string") {
4269
+ try {
4270
+ const p = JSON.parse(value);
4271
+ return p && typeof p === "object" && !Array.isArray(p) ? p : {};
4272
+ } catch {
4273
+ return {};
4274
+ }
4275
+ }
4276
+ return {};
4277
+ }
4278
+ var DEFAULT_GROUP_MAX_INSTANCES = {
4279
+ intake: 1,
4280
+ harvest: 1,
4281
+ loader: 0,
4282
+ photos: 0,
4283
+ photosprocessor: 0,
4284
+ ingest: 0
4285
+ };
4286
+ function sanitizeNamePart(raw) {
4287
+ const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
4288
+ return s.slice(0, 80) || "runner";
4289
+ }
4290
+ function identityFilePath(identityDir, queue, serviceGroup) {
4291
+ const safeQ = sanitizeNamePart(queue);
4292
+ const safeG = sanitizeNamePart(serviceGroup);
4293
+ return import_node_path.default.join(identityDir, `${safeQ}_${safeG}.json`);
4294
+ }
4295
+ async function readIdentityFile(filePath) {
4296
+ try {
4297
+ const text = await (0, import_promises.readFile)(filePath, "utf8");
4298
+ const parsed = JSON.parse(text);
4299
+ return parsed && typeof parsed === "object" ? parsed : {};
4300
+ } catch {
4301
+ return {};
4302
+ }
4303
+ }
4304
+ async function writeIdentityFile(filePath, data) {
4305
+ await (0, import_promises.mkdir)(import_node_path.default.dirname(filePath), { recursive: true });
4306
+ await (0, import_promises.writeFile)(filePath, `${JSON.stringify(data, null, 2)}
4307
+ `, "utf8");
4308
+ }
4309
+ function resolveMaxInstances(serviceGroup, override) {
4310
+ if (override !== void 0 && Number.isFinite(override)) {
4311
+ return Math.max(0, Math.floor(Number(override)));
4312
+ }
4313
+ const g = serviceGroup.trim().toLowerCase();
4314
+ return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
4315
+ }
4316
+ async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
4317
+ const cutoff = new Date(Date.now() - staleMs);
4318
+ let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
4319
+ if (excludeInstanceId) {
4320
+ q = q.whereNot("instance_id", excludeInstanceId);
4321
+ }
4322
+ const row = await q.count("id as count").first();
4323
+ return Number(row?.count ?? 0);
4324
+ }
4325
+ function isUniqueViolation(error) {
4326
+ const code = error?.code ?? error?.errno;
4327
+ return code === "23505" || String(error?.message || "").includes("duplicate key");
4328
+ }
4329
+ async function registerInServicesRegistry(context, options) {
4330
+ const db = getDb2(context);
4331
+ const registryTable = servicesRegistryTable(options.queue);
4332
+ const serviceGroup = options.serviceGroup.trim();
4333
+ if (!serviceGroup) {
4334
+ throw new Error("registerInServicesRegistry: serviceGroup is required");
4335
+ }
4336
+ const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
4337
+ let identity = await readIdentityFile(identityPath);
4338
+ let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : (0, import_node_crypto2.randomUUID)();
4339
+ identity.instanceId = instanceId;
4340
+ await writeIdentityFile(identityPath, identity);
4341
+ const hostname = import_node_os.default.hostname();
4342
+ const pid = typeof process.pid === "number" ? process.pid : null;
4343
+ const meta = toJsonColumn(options.metadata ?? null);
4344
+ const existing = await db(registryTable).where({ instance_id: instanceId }).first();
4345
+ if (existing) {
4346
+ await db(registryTable).where({ instance_id: instanceId }).update({
4347
+ target: options.target,
4348
+ hostname,
4349
+ pid,
4350
+ metadata: meta,
4351
+ last_seen_at: db.fn.now()
4352
+ });
4353
+ const serviceName = String(existing.service_name);
4354
+ identity.serviceName = serviceName;
4355
+ await writeIdentityFile(identityPath, identity);
4356
+ const reg = {
4357
+ instanceId,
4358
+ serviceName,
4359
+ serviceGroup,
4360
+ queue: options.queue,
4361
+ target: options.target,
4362
+ rowId: String(existing.id)
4363
+ };
4364
+ context.servicesRegistry = reg;
4365
+ context.runnerHeartbeat = reg;
4366
+ context.logger.info?.(
4367
+ `[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
4368
+ );
4369
+ return {
4370
+ instanceId,
4371
+ serviceName,
4372
+ serviceGroup,
4373
+ queue: options.queue,
4374
+ target: options.target,
4375
+ rowId: String(existing.id),
4376
+ registryTable
4377
+ };
4378
+ }
4379
+ const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
4380
+ const aliveOthers = await countAliveInGroup(
4381
+ db,
4382
+ registryTable,
4383
+ options.queue,
4384
+ serviceGroup,
4385
+ options.staleMs,
4386
+ instanceId
4387
+ );
4388
+ if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
4389
+ const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
4390
+ if (options.enforceMaxInstances) {
4391
+ throw new Error(msg);
4392
+ }
4393
+ context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
4394
+ }
4395
+ const explicitName = options.serviceName?.trim();
4396
+ const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
4397
+ const hostBase = sanitizeNamePart(hostname);
4398
+ const groupBase = sanitizeNamePart(serviceGroup);
4399
+ const baseCandidates = [];
4400
+ if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
4401
+ if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
4402
+ baseCandidates.push(`${groupBase}-${hostBase}`);
4403
+ baseCandidates.push(groupBase);
4404
+ function* eachServiceNameCandidate(bases) {
4405
+ const seen = /* @__PURE__ */ new Set();
4406
+ for (const rawBase of bases) {
4407
+ const base = sanitizeNamePart(rawBase);
4408
+ if (!base) continue;
4409
+ const seq = [base];
4410
+ for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
4411
+ for (const c of seq) {
4412
+ if (seen.has(c)) continue;
4413
+ seen.add(c);
4414
+ yield c;
4415
+ }
4416
+ }
4417
+ }
4418
+ let inserted;
4419
+ for (const candidate of eachServiceNameCandidate(baseCandidates)) {
4420
+ try {
4421
+ const rows = await db(registryTable).insert({
4422
+ instance_id: instanceId,
4423
+ queue: options.queue,
4424
+ service_group: serviceGroup,
4425
+ service_name: candidate,
4426
+ target: options.target,
4427
+ hostname,
4428
+ pid,
4429
+ metadata: meta,
4430
+ last_seen_at: db.fn.now()
4431
+ }).returning(["id", "service_name"]);
4432
+ const row = Array.isArray(rows) ? rows[0] : rows;
4433
+ if (row) {
4434
+ inserted = { id: String(row.id), service_name: String(row.service_name) };
4435
+ break;
4436
+ }
4437
+ } catch (error) {
4438
+ if (!isUniqueViolation(error)) {
4439
+ throw error;
4440
+ }
4441
+ }
4442
+ }
4443
+ if (!inserted) {
4444
+ throw new Error(
4445
+ `[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
4446
+ );
4447
+ }
4448
+ identity.serviceName = inserted.service_name;
4449
+ await writeIdentityFile(identityPath, identity);
4450
+ const regNew = {
4451
+ instanceId,
4452
+ serviceName: inserted.service_name,
4453
+ serviceGroup,
4454
+ queue: options.queue,
4455
+ target: options.target,
4456
+ rowId: inserted.id
4457
+ };
4458
+ context.servicesRegistry = regNew;
4459
+ context.runnerHeartbeat = regNew;
4460
+ context.logger.info?.(
4461
+ `[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
4462
+ );
4463
+ return {
4464
+ instanceId,
4465
+ serviceName: inserted.service_name,
4466
+ serviceGroup,
4467
+ queue: options.queue,
4468
+ target: options.target,
4469
+ rowId: inserted.id,
4470
+ registryTable
4471
+ };
4472
+ }
4473
+ async function touchServicesRegistry(context, registration) {
4474
+ const db = getDb2(context);
4475
+ const hostname = import_node_os.default.hostname();
4476
+ const pid = typeof process.pid === "number" ? process.pid : null;
4477
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
4478
+ last_seen_at: db.fn.now(),
4479
+ hostname,
4480
+ pid
4481
+ });
4482
+ }
4483
+ async function updateServicesRegistryMetadata(context, registration, patch) {
4484
+ const db = getDb2(context);
4485
+ const row = await db(registration.registryTable).where({ instance_id: registration.instanceId }).first();
4486
+ const prev = parseMetadataColumn(row?.metadata);
4487
+ const merged = { ...prev, ...patch };
4488
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
4489
+ metadata: toJsonColumn(merged),
4490
+ last_seen_at: db.fn.now()
4491
+ });
4492
+ context.logger.info?.(`[services-registry] metadata updated for ${registration.serviceName}`);
4493
+ }
4494
+ async function unregisterServicesRegistry(context, registration) {
4495
+ const db = getDb2(context);
4496
+ await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
4497
+ context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
4498
+ }
4499
+ async function listServicesRegistry(context, options = { queue: "tasks" }) {
4500
+ const db = getDb2(context);
4501
+ const staleMs = options.staleMs ?? 6e4;
4502
+ const cutoff = new Date(Date.now() - staleMs);
4503
+ const table = servicesRegistryTable(options.queue);
4504
+ let q = db(table).where("last_seen_at", ">", cutoff).orderBy([{ column: "service_group", order: "asc" }, { column: "service_name", order: "asc" }]);
4505
+ if (options.serviceGroup?.trim()) {
4506
+ q = q.where({ service_group: options.serviceGroup.trim() });
4507
+ }
4508
+ return await q;
4509
+ }
4510
+
4217
4511
  // src/tasks/taskLogs.ts
4218
4512
  function getLogsState(context) {
4219
4513
  const holder = context;
@@ -4556,8 +4850,8 @@ var TaskShellCommand = class extends TaskMaster {
4556
4850
  };
4557
4851
 
4558
4852
  // src/tasks/coreTasks/TaskSystemInfo.ts
4559
- var import_node_os = __toESM(require("os"), 1);
4560
- var import_promises = __toESM(require("fs/promises"), 1);
4853
+ var import_node_os2 = __toESM(require("os"), 1);
4854
+ var import_promises2 = __toESM(require("fs/promises"), 1);
4561
4855
  function toGb(valueBytes) {
4562
4856
  return `${(valueBytes / 1024 ** 3).toFixed(2)} GB`;
4563
4857
  }
@@ -4565,7 +4859,7 @@ function toMb(valueBytes) {
4565
4859
  return `${(valueBytes / 1024 ** 2).toFixed(2)} MB`;
4566
4860
  }
4567
4861
  async function getDiskStats() {
4568
- const stats = await import_promises.default.statfs("/");
4862
+ const stats = await import_promises2.default.statfs("/");
4569
4863
  const total = Number(stats.bsize) * Number(stats.blocks);
4570
4864
  const free = Number(stats.bsize) * Number(stats.bavail);
4571
4865
  const used = total - free;
@@ -4578,10 +4872,10 @@ async function getDiskStats() {
4578
4872
  var TaskSystemInfo = class extends TaskMaster {
4579
4873
  async run() {
4580
4874
  try {
4581
- const totalMemory = import_node_os.default.totalmem();
4582
- const freeMemory = import_node_os.default.freemem();
4875
+ const totalMemory = import_node_os2.default.totalmem();
4876
+ const freeMemory = import_node_os2.default.freemem();
4583
4877
  const usedMemory = totalMemory - freeMemory;
4584
- const cpus = import_node_os.default.cpus();
4878
+ const cpus = import_node_os2.default.cpus();
4585
4879
  const cpuUtilization = cpus.map((cpu) => {
4586
4880
  const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0);
4587
4881
  const usage = (total - cpu.times.idle) / total * 100;
@@ -4607,10 +4901,10 @@ var TaskSystemInfo = class extends TaskMaster {
4607
4901
  utilization: cpuUtilization
4608
4902
  },
4609
4903
  runtime: {
4610
- platform: import_node_os.default.platform(),
4611
- arch: import_node_os.default.arch(),
4612
- uptimeSec: import_node_os.default.uptime(),
4613
- hostname: import_node_os.default.hostname()
4904
+ platform: import_node_os2.default.platform(),
4905
+ arch: import_node_os2.default.arch(),
4906
+ uptimeSec: import_node_os2.default.uptime(),
4907
+ hostname: import_node_os2.default.hostname()
4614
4908
  }
4615
4909
  };
4616
4910
  this.context.logger.info?.(`[TaskSystemInfo] collected system metrics (${this.task.id})`);
@@ -4843,7 +5137,7 @@ async function runNodeTaskScript(context, options) {
4843
5137
  // src/tasks/index.ts
4844
5138
  var LOCKED_BY_ERROR_MESSAGE = "locked by error";
4845
5139
  var defaultTasksRegistry = TasksRegistry.withCoreTasks();
4846
- function getDb2(context) {
5140
+ function getDb3(context) {
4847
5141
  const db = context.db;
4848
5142
  if (!db) {
4849
5143
  throw new Error("Tasks component requires context.db. Initialize DB first and attach to context.");
@@ -4887,7 +5181,7 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
4887
5181
  context.emitter.emit("stop", allowanceMs);
4888
5182
  }
4889
5183
  async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
4890
- const db = getDb2(context);
5184
+ const db = getDb3(context);
4891
5185
  const taskName = row.task;
4892
5186
  const TaskClass = registry.get(taskName);
4893
5187
  const { paused_at: _pausedAt, ...rowForHistory } = row;
@@ -4988,7 +5282,7 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
4988
5282
  return { stopRunnerRequested, stopAllowanceMs };
4989
5283
  }
4990
5284
  async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
4991
- const db = getDb2(context);
5285
+ const db = getDb3(context);
4992
5286
  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);
4993
5287
  if (taskNames && taskNames.length > 0) {
4994
5288
  query = query.whereIn("task", taskNames);
@@ -5034,25 +5328,76 @@ async function runTasksLoop(context, options) {
5034
5328
  let stopRequested = false;
5035
5329
  let stopAllowanceMs = 5e3;
5036
5330
  context.__tasksRunnerStop = false;
5037
- while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
5038
- if (!runningStopControlPromise) {
5039
- const claimedStopTask = await claimNextRunnableTask(
5040
- context,
5041
- tasksTable,
5042
- target,
5043
- registry,
5044
- 10,
5045
- ["stopRunner", "stop"]
5046
- );
5047
- if (claimedStopTask) {
5048
- runningStopControlPromise = executeClaimedTask(
5331
+ let registryReg = null;
5332
+ let registryInterval = null;
5333
+ const hbGroup = options.runnerServiceGroup?.trim();
5334
+ if (hbGroup) {
5335
+ const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
5336
+ const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
5337
+ const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
5338
+ const defaultMeta = {
5339
+ component: "tasks-runner",
5340
+ allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
5341
+ };
5342
+ registryReg = await registerInServicesRegistry(context, {
5343
+ queue,
5344
+ target,
5345
+ serviceGroup: hbGroup,
5346
+ serviceName: options.runnerServiceName,
5347
+ identityDir,
5348
+ staleMs,
5349
+ groupMaxInstances: options.runnerGroupMaxInstances,
5350
+ enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
5351
+ metadata: options.runnerMetadata ?? defaultMeta
5352
+ });
5353
+ registryInterval = setInterval(() => {
5354
+ void touchServicesRegistry(context, registryReg).catch((err) => {
5355
+ context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
5356
+ });
5357
+ }, hbIntervalMs);
5358
+ }
5359
+ try {
5360
+ while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
5361
+ if (!runningStopControlPromise) {
5362
+ const claimedStopTask = await claimNextRunnableTask(
5049
5363
  context,
5050
5364
  tasksTable,
5051
- historyTable,
5052
- claimedStopTask,
5365
+ target,
5053
5366
  registry,
5054
- runningTaskInstances
5055
- ).then(async (outcome) => {
5367
+ 10,
5368
+ ["stopRunner", "stop"]
5369
+ );
5370
+ if (claimedStopTask) {
5371
+ runningStopControlPromise = executeClaimedTask(
5372
+ context,
5373
+ tasksTable,
5374
+ historyTable,
5375
+ claimedStopTask,
5376
+ registry,
5377
+ runningTaskInstances
5378
+ ).then(async (outcome) => {
5379
+ if (outcome.stopRunnerRequested && !stopRequested) {
5380
+ stopRequested = true;
5381
+ stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5382
+ context.__tasksRunnerStop = true;
5383
+ await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5384
+ }
5385
+ }).finally(() => {
5386
+ runningStopControlPromise = null;
5387
+ });
5388
+ }
5389
+ }
5390
+ while (runningPromises.size < maxParallel) {
5391
+ const claimed = await claimNextRunnableTask(
5392
+ context,
5393
+ tasksTable,
5394
+ target,
5395
+ registry,
5396
+ scanLimit,
5397
+ allowedTasks
5398
+ );
5399
+ if (!claimed) break;
5400
+ const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
5056
5401
  if (outcome.stopRunnerRequested && !stopRequested) {
5057
5402
  stopRequested = true;
5058
5403
  stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
@@ -5060,54 +5405,46 @@ async function runTasksLoop(context, options) {
5060
5405
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5061
5406
  }
5062
5407
  }).finally(() => {
5063
- runningStopControlPromise = null;
5408
+ runningPromises.delete(p);
5064
5409
  });
5410
+ runningPromises.add(p);
5411
+ }
5412
+ await sleepMs(pollMs);
5413
+ }
5414
+ if (context.isStop() && !stopRequested) {
5415
+ await signalRunningTasksStop(context, runningTaskInstances, 5e3);
5416
+ }
5417
+ if (runningPromises.size > 0) {
5418
+ if (stopRequested) {
5419
+ await Promise.race([
5420
+ Promise.allSettled(Array.from(runningPromises)),
5421
+ sleepMs(stopAllowanceMs).then(() => {
5422
+ context.logger.warn?.(
5423
+ `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
5424
+ );
5425
+ })
5426
+ ]);
5427
+ } else {
5428
+ await Promise.allSettled(Array.from(runningPromises));
5065
5429
  }
5066
5430
  }
5067
- while (runningPromises.size < maxParallel) {
5068
- const claimed = await claimNextRunnableTask(
5069
- context,
5070
- tasksTable,
5071
- target,
5072
- registry,
5073
- scanLimit,
5074
- allowedTasks
5075
- );
5076
- if (!claimed) break;
5077
- const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
5078
- if (outcome.stopRunnerRequested && !stopRequested) {
5079
- stopRequested = true;
5080
- stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
5081
- context.__tasksRunnerStop = true;
5082
- await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
5083
- }
5084
- }).finally(() => {
5085
- runningPromises.delete(p);
5086
- });
5087
- runningPromises.add(p);
5431
+ } finally {
5432
+ if (registryInterval) {
5433
+ clearInterval(registryInterval);
5434
+ registryInterval = null;
5088
5435
  }
5089
- await sleepMs(pollMs);
5090
- }
5091
- if (context.isStop() && !stopRequested) {
5092
- await signalRunningTasksStop(context, runningTaskInstances, 5e3);
5093
- }
5094
- if (runningPromises.size > 0) {
5095
- if (stopRequested) {
5096
- await Promise.race([
5097
- Promise.allSettled(Array.from(runningPromises)),
5098
- sleepMs(stopAllowanceMs).then(() => {
5099
- context.logger.warn?.(
5100
- `[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
5101
- );
5102
- })
5103
- ]);
5104
- } else {
5105
- await Promise.allSettled(Array.from(runningPromises));
5436
+ if (registryReg) {
5437
+ await unregisterServicesRegistry(context, registryReg).catch((err) => {
5438
+ context.logger.warn?.(`[services-registry] unregister failed: ${err?.message ?? String(err)}`);
5439
+ });
5440
+ registryReg = null;
5441
+ delete context.servicesRegistry;
5442
+ delete context.runnerHeartbeat;
5106
5443
  }
5107
5444
  }
5108
5445
  }
5109
5446
  async function waitForTaskResult(context, taskId, options = {}) {
5110
- const db = getDb2(context);
5447
+ const db = getDb3(context);
5111
5448
  const queue = options.queue ?? "tasks";
5112
5449
  const timeoutMs = options.timeoutMs ?? 6e4;
5113
5450
  const pollMs = options.pollMs ?? 500;
@@ -5135,6 +5472,14 @@ var TasksManager = class _TasksManager {
5135
5472
  scanLimit;
5136
5473
  allowedTasks;
5137
5474
  registry;
5475
+ runnerServiceGroup;
5476
+ runnerServiceName;
5477
+ runnerIdentityDir;
5478
+ runnerHeartbeatIntervalMs;
5479
+ runnerHeartbeatStaleMs;
5480
+ runnerGroupMaxInstances;
5481
+ runnerEnforceMaxInstances;
5482
+ runnerMetadata;
5138
5483
  constructor(context, options = {}) {
5139
5484
  this.context = context;
5140
5485
  this.queue = options.queue ?? "tasks";
@@ -5145,6 +5490,14 @@ var TasksManager = class _TasksManager {
5145
5490
  this.scanLimit = options.scanLimit ?? 100;
5146
5491
  this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
5147
5492
  this.registry = normalizeRegistry(options.registry);
5493
+ this.runnerServiceGroup = options.runnerServiceGroup;
5494
+ this.runnerServiceName = options.runnerServiceName;
5495
+ this.runnerIdentityDir = options.runnerIdentityDir;
5496
+ this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
5497
+ this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
5498
+ this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
5499
+ this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
5500
+ this.runnerMetadata = options.runnerMetadata;
5148
5501
  }
5149
5502
  static init(context, options = {}) {
5150
5503
  const defs = {
@@ -5154,7 +5507,14 @@ var TasksManager = class _TasksManager {
5154
5507
  pollMs: "number default 1000",
5155
5508
  maxParallel: "number default 1",
5156
5509
  scanLimit: "number default 100",
5157
- allowedTasks: "string"
5510
+ allowedTasks: "string",
5511
+ runnerServiceGroup: "string",
5512
+ runnerServiceName: "string",
5513
+ runnerIdentityDir: "string default ./data/runner-identities",
5514
+ runnerHeartbeatIntervalMs: "number default 10000",
5515
+ runnerHeartbeatStaleMs: "number default 45000",
5516
+ runnerGroupMaxInstances: "number",
5517
+ runnerEnforceMaxInstances: "boolean default true"
5158
5518
  };
5159
5519
  const discovered = context.params.getAllForModule(defs);
5160
5520
  const resolved = {
@@ -5165,6 +5525,13 @@ var TasksManager = class _TasksManager {
5165
5525
  maxParallel: discovered.maxParallel,
5166
5526
  scanLimit: discovered.scanLimit,
5167
5527
  allowedTasks: discovered.allowedTasks,
5528
+ runnerServiceGroup: discovered.runnerServiceGroup,
5529
+ runnerServiceName: discovered.runnerServiceName,
5530
+ runnerIdentityDir: discovered.runnerIdentityDir,
5531
+ runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
5532
+ runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
5533
+ runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
5534
+ runnerEnforceMaxInstances: discovered.runnerEnforceMaxInstances,
5168
5535
  ...options
5169
5536
  };
5170
5537
  return new _TasksManager(context, resolved);
@@ -5183,7 +5550,15 @@ var TasksManager = class _TasksManager {
5183
5550
  maxParallel: options.maxParallel ?? this.maxParallel,
5184
5551
  scanLimit: options.scanLimit ?? this.scanLimit,
5185
5552
  allowedTasks: options.allowedTasks ?? this.allowedTasks,
5186
- registry: options.registry ?? this.registry
5553
+ registry: options.registry ?? this.registry,
5554
+ runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
5555
+ runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
5556
+ runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
5557
+ runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
5558
+ runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
5559
+ runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
5560
+ runnerEnforceMaxInstances: options.runnerEnforceMaxInstances ?? this.runnerEnforceMaxInstances,
5561
+ runnerMetadata: options.runnerMetadata ?? this.runnerMetadata
5187
5562
  });
5188
5563
  }
5189
5564
  };
@@ -5238,13 +5613,19 @@ var TasksManager = class _TasksManager {
5238
5613
  h,
5239
5614
  joiEdateType,
5240
5615
  joiStringArrayType,
5616
+ listAliveRunnerHeartbeats,
5617
+ listServicesRegistry,
5241
5618
  listSources,
5242
5619
  listTables,
5243
5620
  load,
5244
5621
  organizeFooterMessages,
5245
5622
  queueToTableNames,
5623
+ registerInServicesRegistry,
5624
+ registerRunnerHeartbeat,
5246
5625
  runNodeTaskScript,
5247
5626
  runTasksLoop,
5627
+ runnerHeartbeatsTable,
5628
+ servicesRegistryTable,
5248
5629
  setupContext,
5249
5630
  showListScreen,
5250
5631
  showMenuScreen,
@@ -5252,6 +5633,11 @@ var TasksManager = class _TasksManager {
5252
5633
  showMultiColumnListWithPreviewScreen,
5253
5634
  showScreen,
5254
5635
  showWordGridScreen,
5636
+ touchRunnerHeartbeat,
5637
+ touchServicesRegistry,
5638
+ unregisterRunnerHeartbeat,
5639
+ unregisterServicesRegistry,
5640
+ updateServicesRegistryMetadata,
5255
5641
  updateTaskProgress,
5256
5642
  useCallback,
5257
5643
  useEffect,