@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/README.md +9 -0
- package/dist/cli-runner.cjs +426 -97
- package/dist/cli-runner.cjs.map +1 -1
- package/dist/cli-runner.js +457 -128
- package/dist/cli-runner.js.map +1 -1
- package/dist/db.cjs +1 -1
- package/dist/db.cjs.map +1 -1
- package/dist/db.js +1 -1
- package/dist/db.js.map +1 -1
- package/dist/filedatabase.cjs.map +1 -1
- package/dist/filedatabase.js.map +1 -1
- package/dist/http-client2.cjs +6 -2
- package/dist/http-client2.cjs.map +1 -1
- package/dist/http-client2.js +6 -2
- package/dist/http-client2.js.map +1 -1
- package/dist/index.cjs +482 -96
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +469 -94
- package/dist/index.js.map +1 -1
- package/dist/init.cjs +16 -1
- package/dist/init.cjs.map +1 -1
- package/dist/init.js +16 -1
- package/dist/init.js.map +1 -1
- package/dist/mock-server.cjs.map +1 -1
- package/dist/mock-server.js.map +1 -1
- package/dist/params.cjs +16 -1
- package/dist/params.cjs.map +1 -1
- package/dist/params.js +16 -1
- package/dist/params.js.map +1 -1
- package/dist/tasks.cjs +458 -87
- package/dist/tasks.cjs.map +1 -1
- package/dist/tasks.js +478 -118
- package/dist/tasks.js.map +1 -1
- package/package.json +10 -3
- package/scripts/ssm/parse-cli.ts +35 -0
- package/scripts/ssm/ssm-admin.ts +151 -0
- package/scripts/ssm/ssm-pull.ts +150 -0
package/dist/index.js
CHANGED
|
@@ -901,11 +901,11 @@ function buildBreadcrumb(parts) {
|
|
|
901
901
|
if (parts.length === 1) return parts[0];
|
|
902
902
|
return parts.slice(1).map((part) => `\u2190 ${part}`).join(" ");
|
|
903
903
|
}
|
|
904
|
-
function buildDetailBreadcrumb(
|
|
905
|
-
if (
|
|
906
|
-
return suffix ? `\u2190 ${suffix}` :
|
|
904
|
+
function buildDetailBreadcrumb(path5, suffix = "") {
|
|
905
|
+
if (path5.length <= 1) {
|
|
906
|
+
return suffix ? `\u2190 ${suffix}` : path5[0] || "";
|
|
907
907
|
}
|
|
908
|
-
const breadcrumb = buildBreadcrumb(
|
|
908
|
+
const breadcrumb = buildBreadcrumb(path5);
|
|
909
909
|
return suffix ? `${breadcrumb} ${suffix}` : breadcrumb;
|
|
910
910
|
}
|
|
911
911
|
var init_utils = __esm({
|
|
@@ -1876,7 +1876,7 @@ var Params = class _Params {
|
|
|
1876
1876
|
throw new ParamError(`default value "${defValObj.value}" type mismatch`);
|
|
1877
1877
|
}
|
|
1878
1878
|
type = type.default(defValObj.value);
|
|
1879
|
-
} else if (str.match(
|
|
1879
|
+
} else if (str.match(/\s*required\s*/)) {
|
|
1880
1880
|
type = type.required();
|
|
1881
1881
|
} else {
|
|
1882
1882
|
type = type.optional();
|
|
@@ -1952,6 +1952,8 @@ var Params = class _Params {
|
|
|
1952
1952
|
/**
|
|
1953
1953
|
* Get all parameters from definitions (main script).
|
|
1954
1954
|
* Same as getAllForModule("script", defs). Processes left-to-right for cross-parameter references.
|
|
1955
|
+
* Libraries should use {@link getAllForModule} with an explicit module name (or {@link runWithModule}
|
|
1956
|
+
* around {@link get}) so --showUsedParams groups usage correctly.
|
|
1955
1957
|
*/
|
|
1956
1958
|
getAll(defs) {
|
|
1957
1959
|
return this.getAllForModule("script", defs);
|
|
@@ -1988,6 +1990,19 @@ var Params = class _Params {
|
|
|
1988
1990
|
this._currentModule = prev;
|
|
1989
1991
|
}
|
|
1990
1992
|
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Run a callback with {@link _currentModule} set so single {@link get} calls are tracked
|
|
1995
|
+
* under the same module (for --showUsedParams / getFiguredByModule).
|
|
1996
|
+
*/
|
|
1997
|
+
runWithModule(moduleName, fn) {
|
|
1998
|
+
const prev = this._currentModule;
|
|
1999
|
+
this._currentModule = moduleName;
|
|
2000
|
+
try {
|
|
2001
|
+
return fn();
|
|
2002
|
+
} finally {
|
|
2003
|
+
this._currentModule = prev;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
1991
2006
|
/**
|
|
1992
2007
|
* Infer module name from call stack: first caller outside params/index gives path like .../src/<moduleName>/...
|
|
1993
2008
|
*/
|
|
@@ -2001,9 +2016,9 @@ var Params = class _Params {
|
|
|
2001
2016
|
if (!parenMatch) continue;
|
|
2002
2017
|
const parts = parenMatch[1].split(":");
|
|
2003
2018
|
if (parts.length < 3) continue;
|
|
2004
|
-
const
|
|
2005
|
-
if (!
|
|
2006
|
-
const srcMatch =
|
|
2019
|
+
const path5 = parts.slice(0, -2).join(":").replace(/^file:\/\//, "");
|
|
2020
|
+
if (!path5 || path5.includes(paramsIndexPath)) continue;
|
|
2021
|
+
const srcMatch = path5.match(/[/\\]src[/\\]([^/\\]+)(?:[/\\]|$)/);
|
|
2007
2022
|
if (srcMatch) return srcMatch[1];
|
|
2008
2023
|
}
|
|
2009
2024
|
return "script";
|
|
@@ -3605,7 +3620,7 @@ async function dbFindAndConnect(context, dbNameOrConnectionString) {
|
|
|
3605
3620
|
dbConnectionString: "string",
|
|
3606
3621
|
dbProfile: "boolean default false"
|
|
3607
3622
|
};
|
|
3608
|
-
const paramsConfig = context.params.
|
|
3623
|
+
const paramsConfig = context.params.getAll(defs);
|
|
3609
3624
|
dbName = paramsConfig.dbName;
|
|
3610
3625
|
dbConnectionString = paramsConfig.dbConnectionString;
|
|
3611
3626
|
dbProfile = paramsConfig.dbProfile;
|
|
@@ -3991,6 +4006,12 @@ function toJsonColumn(value) {
|
|
|
3991
4006
|
return JSON.stringify(value);
|
|
3992
4007
|
}
|
|
3993
4008
|
|
|
4009
|
+
// src/tasks/servicesRegistry.ts
|
|
4010
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
4011
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
4012
|
+
import os from "os";
|
|
4013
|
+
import path4 from "path";
|
|
4014
|
+
|
|
3994
4015
|
// src/tasks/taskUtils.ts
|
|
3995
4016
|
import { randomUUID } from "crypto";
|
|
3996
4017
|
function getDb(context) {
|
|
@@ -4009,6 +4030,12 @@ function queueToTableNames(queue) {
|
|
|
4009
4030
|
historyTable: `${queue}_history`
|
|
4010
4031
|
};
|
|
4011
4032
|
}
|
|
4033
|
+
function servicesRegistryTable(queue) {
|
|
4034
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(queue)) {
|
|
4035
|
+
throw new Error(`Invalid queue name "${queue}". Use letters, numbers, underscore only.`);
|
|
4036
|
+
}
|
|
4037
|
+
return `${queue}_services_registry`;
|
|
4038
|
+
}
|
|
4012
4039
|
async function ensureTaskTables(context, options = {}) {
|
|
4013
4040
|
const queue = options.queue ?? "tasks";
|
|
4014
4041
|
const recreate = options.recreate ?? false;
|
|
@@ -4045,18 +4072,6 @@ async function ensureTaskTables(context, options = {}) {
|
|
|
4045
4072
|
t.index(["target", "task"], `${tasksTable}_target_task_idx`);
|
|
4046
4073
|
});
|
|
4047
4074
|
}
|
|
4048
|
-
const tasksHasOpid = await db.schema.hasColumn(tasksTable, "opid");
|
|
4049
|
-
if (!tasksHasOpid) {
|
|
4050
|
-
await db.schema.alterTable(tasksTable, (t) => {
|
|
4051
|
-
t.text("opid");
|
|
4052
|
-
});
|
|
4053
|
-
}
|
|
4054
|
-
const tasksHasPausedAt = await db.schema.hasColumn(tasksTable, "paused_at");
|
|
4055
|
-
if (!tasksHasPausedAt) {
|
|
4056
|
-
await db.schema.alterTable(tasksTable, (t) => {
|
|
4057
|
-
t.timestamp("paused_at").defaultTo(null);
|
|
4058
|
-
});
|
|
4059
|
-
}
|
|
4060
4075
|
if (needsHistory) {
|
|
4061
4076
|
await db.schema.createTable(historyTable, (t) => {
|
|
4062
4077
|
t.uuid("id").notNullable();
|
|
@@ -4079,10 +4094,24 @@ async function ensureTaskTables(context, options = {}) {
|
|
|
4079
4094
|
t.index(["task", "created_at"], `${historyTable}_task_created_idx`);
|
|
4080
4095
|
});
|
|
4081
4096
|
}
|
|
4082
|
-
const
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4097
|
+
const registryTable = servicesRegistryTable(queue);
|
|
4098
|
+
const needsRegistry = !await db.tableExists(registryTable);
|
|
4099
|
+
if (needsRegistry) {
|
|
4100
|
+
await db.schema.createTable(registryTable, (t) => {
|
|
4101
|
+
t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
|
|
4102
|
+
t.uuid("instance_id").notNullable().unique();
|
|
4103
|
+
t.text("queue").notNullable();
|
|
4104
|
+
t.text("service_group").notNullable();
|
|
4105
|
+
t.text("service_name").notNullable();
|
|
4106
|
+
t.text("target").notNullable();
|
|
4107
|
+
t.text("hostname");
|
|
4108
|
+
t.integer("pid");
|
|
4109
|
+
t.json("metadata");
|
|
4110
|
+
t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
|
|
4111
|
+
t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
|
|
4112
|
+
t.unique(["queue", "service_name"], `${registryTable}_queue_service_name_uniq`);
|
|
4113
|
+
t.index(["queue", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
|
|
4114
|
+
t.index(["queue", "last_seen_at"], `${registryTable}_queue_seen_idx`);
|
|
4086
4115
|
});
|
|
4087
4116
|
}
|
|
4088
4117
|
}
|
|
@@ -4109,6 +4138,260 @@ async function updateTaskProgress(context, tasksTable, taskId, progress) {
|
|
|
4109
4138
|
});
|
|
4110
4139
|
}
|
|
4111
4140
|
|
|
4141
|
+
// src/tasks/servicesRegistry.ts
|
|
4142
|
+
function getDb2(context) {
|
|
4143
|
+
const db = context.db;
|
|
4144
|
+
if (!db) {
|
|
4145
|
+
throw new Error("Services registry requires context.db");
|
|
4146
|
+
}
|
|
4147
|
+
return db;
|
|
4148
|
+
}
|
|
4149
|
+
function parseMetadataColumn(value) {
|
|
4150
|
+
if (!value) return {};
|
|
4151
|
+
if (typeof value === "object" && !Array.isArray(value)) return value;
|
|
4152
|
+
if (typeof value === "string") {
|
|
4153
|
+
try {
|
|
4154
|
+
const p = JSON.parse(value);
|
|
4155
|
+
return p && typeof p === "object" && !Array.isArray(p) ? p : {};
|
|
4156
|
+
} catch {
|
|
4157
|
+
return {};
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
return {};
|
|
4161
|
+
}
|
|
4162
|
+
var DEFAULT_GROUP_MAX_INSTANCES = {
|
|
4163
|
+
intake: 1,
|
|
4164
|
+
harvest: 1,
|
|
4165
|
+
loader: 0,
|
|
4166
|
+
photos: 0,
|
|
4167
|
+
photosprocessor: 0,
|
|
4168
|
+
ingest: 0
|
|
4169
|
+
};
|
|
4170
|
+
function sanitizeNamePart(raw) {
|
|
4171
|
+
const s = String(raw || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4172
|
+
return s.slice(0, 80) || "runner";
|
|
4173
|
+
}
|
|
4174
|
+
function identityFilePath(identityDir, queue, serviceGroup) {
|
|
4175
|
+
const safeQ = sanitizeNamePart(queue);
|
|
4176
|
+
const safeG = sanitizeNamePart(serviceGroup);
|
|
4177
|
+
return path4.join(identityDir, `${safeQ}_${safeG}.json`);
|
|
4178
|
+
}
|
|
4179
|
+
async function readIdentityFile(filePath) {
|
|
4180
|
+
try {
|
|
4181
|
+
const text = await readFile(filePath, "utf8");
|
|
4182
|
+
const parsed = JSON.parse(text);
|
|
4183
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
4184
|
+
} catch {
|
|
4185
|
+
return {};
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
async function writeIdentityFile(filePath, data) {
|
|
4189
|
+
await mkdir(path4.dirname(filePath), { recursive: true });
|
|
4190
|
+
await writeFile(filePath, `${JSON.stringify(data, null, 2)}
|
|
4191
|
+
`, "utf8");
|
|
4192
|
+
}
|
|
4193
|
+
function resolveMaxInstances(serviceGroup, override) {
|
|
4194
|
+
if (override !== void 0 && Number.isFinite(override)) {
|
|
4195
|
+
return Math.max(0, Math.floor(Number(override)));
|
|
4196
|
+
}
|
|
4197
|
+
const g = serviceGroup.trim().toLowerCase();
|
|
4198
|
+
return DEFAULT_GROUP_MAX_INSTANCES[g] ?? 0;
|
|
4199
|
+
}
|
|
4200
|
+
async function countAliveInGroup(db, registryTable, queue, serviceGroup, staleMs, excludeInstanceId) {
|
|
4201
|
+
const cutoff = new Date(Date.now() - staleMs);
|
|
4202
|
+
let q = db(registryTable).where({ queue, service_group: serviceGroup }).where("last_seen_at", ">", cutoff);
|
|
4203
|
+
if (excludeInstanceId) {
|
|
4204
|
+
q = q.whereNot("instance_id", excludeInstanceId);
|
|
4205
|
+
}
|
|
4206
|
+
const row = await q.count("id as count").first();
|
|
4207
|
+
return Number(row?.count ?? 0);
|
|
4208
|
+
}
|
|
4209
|
+
function isUniqueViolation(error) {
|
|
4210
|
+
const code = error?.code ?? error?.errno;
|
|
4211
|
+
return code === "23505" || String(error?.message || "").includes("duplicate key");
|
|
4212
|
+
}
|
|
4213
|
+
async function registerInServicesRegistry(context, options) {
|
|
4214
|
+
const db = getDb2(context);
|
|
4215
|
+
const registryTable = servicesRegistryTable(options.queue);
|
|
4216
|
+
const serviceGroup = options.serviceGroup.trim();
|
|
4217
|
+
if (!serviceGroup) {
|
|
4218
|
+
throw new Error("registerInServicesRegistry: serviceGroup is required");
|
|
4219
|
+
}
|
|
4220
|
+
const identityPath = identityFilePath(options.identityDir, options.queue, serviceGroup);
|
|
4221
|
+
let identity = await readIdentityFile(identityPath);
|
|
4222
|
+
let instanceId = typeof identity.instanceId === "string" && identity.instanceId.trim() ? identity.instanceId.trim() : randomUUID2();
|
|
4223
|
+
identity.instanceId = instanceId;
|
|
4224
|
+
await writeIdentityFile(identityPath, identity);
|
|
4225
|
+
const hostname = os.hostname();
|
|
4226
|
+
const pid = typeof process.pid === "number" ? process.pid : null;
|
|
4227
|
+
const meta = toJsonColumn(options.metadata ?? null);
|
|
4228
|
+
const existing = await db(registryTable).where({ instance_id: instanceId }).first();
|
|
4229
|
+
if (existing) {
|
|
4230
|
+
await db(registryTable).where({ instance_id: instanceId }).update({
|
|
4231
|
+
target: options.target,
|
|
4232
|
+
hostname,
|
|
4233
|
+
pid,
|
|
4234
|
+
metadata: meta,
|
|
4235
|
+
last_seen_at: db.fn.now()
|
|
4236
|
+
});
|
|
4237
|
+
const serviceName = String(existing.service_name);
|
|
4238
|
+
identity.serviceName = serviceName;
|
|
4239
|
+
await writeIdentityFile(identityPath, identity);
|
|
4240
|
+
const reg = {
|
|
4241
|
+
instanceId,
|
|
4242
|
+
serviceName,
|
|
4243
|
+
serviceGroup,
|
|
4244
|
+
queue: options.queue,
|
|
4245
|
+
target: options.target,
|
|
4246
|
+
rowId: String(existing.id)
|
|
4247
|
+
};
|
|
4248
|
+
context.servicesRegistry = reg;
|
|
4249
|
+
context.runnerHeartbeat = reg;
|
|
4250
|
+
context.logger.info?.(
|
|
4251
|
+
`[services-registry] resumed instance_id=${instanceId} name=${serviceName} group=${serviceGroup} queue=${options.queue}`
|
|
4252
|
+
);
|
|
4253
|
+
return {
|
|
4254
|
+
instanceId,
|
|
4255
|
+
serviceName,
|
|
4256
|
+
serviceGroup,
|
|
4257
|
+
queue: options.queue,
|
|
4258
|
+
target: options.target,
|
|
4259
|
+
rowId: String(existing.id),
|
|
4260
|
+
registryTable
|
|
4261
|
+
};
|
|
4262
|
+
}
|
|
4263
|
+
const maxAllowed = resolveMaxInstances(serviceGroup, options.groupMaxInstances);
|
|
4264
|
+
const aliveOthers = await countAliveInGroup(
|
|
4265
|
+
db,
|
|
4266
|
+
registryTable,
|
|
4267
|
+
options.queue,
|
|
4268
|
+
serviceGroup,
|
|
4269
|
+
options.staleMs,
|
|
4270
|
+
instanceId
|
|
4271
|
+
);
|
|
4272
|
+
if (maxAllowed > 0 && aliveOthers >= maxAllowed) {
|
|
4273
|
+
const msg = `[services-registry] group limit reached for "${serviceGroup}": ${aliveOthers} alive (max ${maxAllowed}, queue=${options.queue}).`;
|
|
4274
|
+
if (options.enforceMaxInstances) {
|
|
4275
|
+
throw new Error(msg);
|
|
4276
|
+
}
|
|
4277
|
+
context.logger.warn?.(`${msg} Starting anyway (runnerEnforceMaxInstances=false).`);
|
|
4278
|
+
}
|
|
4279
|
+
const explicitName = options.serviceName?.trim();
|
|
4280
|
+
const fromFile = typeof identity.serviceName === "string" ? identity.serviceName.trim() : "";
|
|
4281
|
+
const hostBase = sanitizeNamePart(hostname);
|
|
4282
|
+
const groupBase = sanitizeNamePart(serviceGroup);
|
|
4283
|
+
const baseCandidates = [];
|
|
4284
|
+
if (explicitName) baseCandidates.push(sanitizeNamePart(explicitName));
|
|
4285
|
+
if (fromFile) baseCandidates.push(sanitizeNamePart(fromFile));
|
|
4286
|
+
baseCandidates.push(`${groupBase}-${hostBase}`);
|
|
4287
|
+
baseCandidates.push(groupBase);
|
|
4288
|
+
function* eachServiceNameCandidate(bases) {
|
|
4289
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4290
|
+
for (const rawBase of bases) {
|
|
4291
|
+
const base = sanitizeNamePart(rawBase);
|
|
4292
|
+
if (!base) continue;
|
|
4293
|
+
const seq = [base];
|
|
4294
|
+
for (let n = 2; n <= 500; n++) seq.push(`${base}-${n}`);
|
|
4295
|
+
for (const c of seq) {
|
|
4296
|
+
if (seen.has(c)) continue;
|
|
4297
|
+
seen.add(c);
|
|
4298
|
+
yield c;
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
let inserted;
|
|
4303
|
+
for (const candidate of eachServiceNameCandidate(baseCandidates)) {
|
|
4304
|
+
try {
|
|
4305
|
+
const rows = await db(registryTable).insert({
|
|
4306
|
+
instance_id: instanceId,
|
|
4307
|
+
queue: options.queue,
|
|
4308
|
+
service_group: serviceGroup,
|
|
4309
|
+
service_name: candidate,
|
|
4310
|
+
target: options.target,
|
|
4311
|
+
hostname,
|
|
4312
|
+
pid,
|
|
4313
|
+
metadata: meta,
|
|
4314
|
+
last_seen_at: db.fn.now()
|
|
4315
|
+
}).returning(["id", "service_name"]);
|
|
4316
|
+
const row = Array.isArray(rows) ? rows[0] : rows;
|
|
4317
|
+
if (row) {
|
|
4318
|
+
inserted = { id: String(row.id), service_name: String(row.service_name) };
|
|
4319
|
+
break;
|
|
4320
|
+
}
|
|
4321
|
+
} catch (error) {
|
|
4322
|
+
if (!isUniqueViolation(error)) {
|
|
4323
|
+
throw error;
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
if (!inserted) {
|
|
4328
|
+
throw new Error(
|
|
4329
|
+
`[services-registry] could not allocate a unique service_name for group=${serviceGroup} queue=${options.queue} (too many collisions).`
|
|
4330
|
+
);
|
|
4331
|
+
}
|
|
4332
|
+
identity.serviceName = inserted.service_name;
|
|
4333
|
+
await writeIdentityFile(identityPath, identity);
|
|
4334
|
+
const regNew = {
|
|
4335
|
+
instanceId,
|
|
4336
|
+
serviceName: inserted.service_name,
|
|
4337
|
+
serviceGroup,
|
|
4338
|
+
queue: options.queue,
|
|
4339
|
+
target: options.target,
|
|
4340
|
+
rowId: inserted.id
|
|
4341
|
+
};
|
|
4342
|
+
context.servicesRegistry = regNew;
|
|
4343
|
+
context.runnerHeartbeat = regNew;
|
|
4344
|
+
context.logger.info?.(
|
|
4345
|
+
`[services-registry] registered instance_id=${instanceId} name=${inserted.service_name} group=${serviceGroup} queue=${options.queue} target=${options.target}`
|
|
4346
|
+
);
|
|
4347
|
+
return {
|
|
4348
|
+
instanceId,
|
|
4349
|
+
serviceName: inserted.service_name,
|
|
4350
|
+
serviceGroup,
|
|
4351
|
+
queue: options.queue,
|
|
4352
|
+
target: options.target,
|
|
4353
|
+
rowId: inserted.id,
|
|
4354
|
+
registryTable
|
|
4355
|
+
};
|
|
4356
|
+
}
|
|
4357
|
+
async function touchServicesRegistry(context, registration) {
|
|
4358
|
+
const db = getDb2(context);
|
|
4359
|
+
const hostname = os.hostname();
|
|
4360
|
+
const pid = typeof process.pid === "number" ? process.pid : null;
|
|
4361
|
+
await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
|
|
4362
|
+
last_seen_at: db.fn.now(),
|
|
4363
|
+
hostname,
|
|
4364
|
+
pid
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
async function updateServicesRegistryMetadata(context, registration, patch) {
|
|
4368
|
+
const db = getDb2(context);
|
|
4369
|
+
const row = await db(registration.registryTable).where({ instance_id: registration.instanceId }).first();
|
|
4370
|
+
const prev = parseMetadataColumn(row?.metadata);
|
|
4371
|
+
const merged = { ...prev, ...patch };
|
|
4372
|
+
await db(registration.registryTable).where({ instance_id: registration.instanceId }).update({
|
|
4373
|
+
metadata: toJsonColumn(merged),
|
|
4374
|
+
last_seen_at: db.fn.now()
|
|
4375
|
+
});
|
|
4376
|
+
context.logger.info?.(`[services-registry] metadata updated for ${registration.serviceName}`);
|
|
4377
|
+
}
|
|
4378
|
+
async function unregisterServicesRegistry(context, registration) {
|
|
4379
|
+
const db = getDb2(context);
|
|
4380
|
+
await db(registration.registryTable).where({ instance_id: registration.instanceId }).delete();
|
|
4381
|
+
context.logger.info?.(`[services-registry] unregistered name=${registration.serviceName} instance_id=${registration.instanceId}`);
|
|
4382
|
+
}
|
|
4383
|
+
async function listServicesRegistry(context, options = { queue: "tasks" }) {
|
|
4384
|
+
const db = getDb2(context);
|
|
4385
|
+
const staleMs = options.staleMs ?? 6e4;
|
|
4386
|
+
const cutoff = new Date(Date.now() - staleMs);
|
|
4387
|
+
const table = servicesRegistryTable(options.queue);
|
|
4388
|
+
let q = db(table).where("last_seen_at", ">", cutoff).orderBy([{ column: "service_group", order: "asc" }, { column: "service_name", order: "asc" }]);
|
|
4389
|
+
if (options.serviceGroup?.trim()) {
|
|
4390
|
+
q = q.where({ service_group: options.serviceGroup.trim() });
|
|
4391
|
+
}
|
|
4392
|
+
return await q;
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4112
4395
|
// src/tasks/taskLogs.ts
|
|
4113
4396
|
function getLogsState(context) {
|
|
4114
4397
|
const holder = context;
|
|
@@ -4451,7 +4734,7 @@ var TaskShellCommand = class extends TaskMaster {
|
|
|
4451
4734
|
};
|
|
4452
4735
|
|
|
4453
4736
|
// src/tasks/coreTasks/TaskSystemInfo.ts
|
|
4454
|
-
import
|
|
4737
|
+
import os2 from "os";
|
|
4455
4738
|
import fs4 from "fs/promises";
|
|
4456
4739
|
function toGb(valueBytes) {
|
|
4457
4740
|
return `${(valueBytes / 1024 ** 3).toFixed(2)} GB`;
|
|
@@ -4473,10 +4756,10 @@ async function getDiskStats() {
|
|
|
4473
4756
|
var TaskSystemInfo = class extends TaskMaster {
|
|
4474
4757
|
async run() {
|
|
4475
4758
|
try {
|
|
4476
|
-
const totalMemory =
|
|
4477
|
-
const freeMemory =
|
|
4759
|
+
const totalMemory = os2.totalmem();
|
|
4760
|
+
const freeMemory = os2.freemem();
|
|
4478
4761
|
const usedMemory = totalMemory - freeMemory;
|
|
4479
|
-
const cpus =
|
|
4762
|
+
const cpus = os2.cpus();
|
|
4480
4763
|
const cpuUtilization = cpus.map((cpu) => {
|
|
4481
4764
|
const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0);
|
|
4482
4765
|
const usage = (total - cpu.times.idle) / total * 100;
|
|
@@ -4502,10 +4785,10 @@ var TaskSystemInfo = class extends TaskMaster {
|
|
|
4502
4785
|
utilization: cpuUtilization
|
|
4503
4786
|
},
|
|
4504
4787
|
runtime: {
|
|
4505
|
-
platform:
|
|
4506
|
-
arch:
|
|
4507
|
-
uptimeSec:
|
|
4508
|
-
hostname:
|
|
4788
|
+
platform: os2.platform(),
|
|
4789
|
+
arch: os2.arch(),
|
|
4790
|
+
uptimeSec: os2.uptime(),
|
|
4791
|
+
hostname: os2.hostname()
|
|
4509
4792
|
}
|
|
4510
4793
|
};
|
|
4511
4794
|
this.context.logger.info?.(`[TaskSystemInfo] collected system metrics (${this.task.id})`);
|
|
@@ -4738,7 +5021,7 @@ async function runNodeTaskScript(context, options) {
|
|
|
4738
5021
|
// src/tasks/index.ts
|
|
4739
5022
|
var LOCKED_BY_ERROR_MESSAGE = "locked by error";
|
|
4740
5023
|
var defaultTasksRegistry = TasksRegistry.withCoreTasks();
|
|
4741
|
-
function
|
|
5024
|
+
function getDb3(context) {
|
|
4742
5025
|
const db = context.db;
|
|
4743
5026
|
if (!db) {
|
|
4744
5027
|
throw new Error("Tasks component requires context.db. Initialize DB first and attach to context.");
|
|
@@ -4782,7 +5065,7 @@ async function signalRunningTasksStop(context, runningTaskInstances, allowanceMs
|
|
|
4782
5065
|
context.emitter.emit("stop", allowanceMs);
|
|
4783
5066
|
}
|
|
4784
5067
|
async function executeClaimedTask(context, tasksTable, historyTable, row, registry, runningTaskInstances) {
|
|
4785
|
-
const db =
|
|
5068
|
+
const db = getDb3(context);
|
|
4786
5069
|
const taskName = row.task;
|
|
4787
5070
|
const TaskClass = registry.get(taskName);
|
|
4788
5071
|
const { paused_at: _pausedAt, ...rowForHistory } = row;
|
|
@@ -4883,7 +5166,7 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
|
|
|
4883
5166
|
return { stopRunnerRequested, stopAllowanceMs };
|
|
4884
5167
|
}
|
|
4885
5168
|
async function claimNextRunnableTask(context, tasksTable, target, registry, scanLimit, taskNames) {
|
|
4886
|
-
const db =
|
|
5169
|
+
const db = getDb3(context);
|
|
4887
5170
|
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);
|
|
4888
5171
|
if (taskNames && taskNames.length > 0) {
|
|
4889
5172
|
query = query.whereIn("task", taskNames);
|
|
@@ -4929,25 +5212,76 @@ async function runTasksLoop(context, options) {
|
|
|
4929
5212
|
let stopRequested = false;
|
|
4930
5213
|
let stopAllowanceMs = 5e3;
|
|
4931
5214
|
context.__tasksRunnerStop = false;
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
)
|
|
4942
|
-
|
|
4943
|
-
|
|
5215
|
+
let registryReg = null;
|
|
5216
|
+
let registryInterval = null;
|
|
5217
|
+
const hbGroup = options.runnerServiceGroup?.trim();
|
|
5218
|
+
if (hbGroup) {
|
|
5219
|
+
const identityDir = options.runnerIdentityDir ?? "./data/runner-identities";
|
|
5220
|
+
const hbIntervalMs = options.runnerHeartbeatIntervalMs ?? 1e4;
|
|
5221
|
+
const staleMs = options.runnerHeartbeatStaleMs ?? 45e3;
|
|
5222
|
+
const defaultMeta = {
|
|
5223
|
+
component: "tasks-runner",
|
|
5224
|
+
allowedTasks: allowedTasks?.length ? allowedTasks.join(",") : "all"
|
|
5225
|
+
};
|
|
5226
|
+
registryReg = await registerInServicesRegistry(context, {
|
|
5227
|
+
queue,
|
|
5228
|
+
target,
|
|
5229
|
+
serviceGroup: hbGroup,
|
|
5230
|
+
serviceName: options.runnerServiceName,
|
|
5231
|
+
identityDir,
|
|
5232
|
+
staleMs,
|
|
5233
|
+
groupMaxInstances: options.runnerGroupMaxInstances,
|
|
5234
|
+
enforceMaxInstances: options.runnerEnforceMaxInstances ?? true,
|
|
5235
|
+
metadata: options.runnerMetadata ?? defaultMeta
|
|
5236
|
+
});
|
|
5237
|
+
registryInterval = setInterval(() => {
|
|
5238
|
+
void touchServicesRegistry(context, registryReg).catch((err) => {
|
|
5239
|
+
context.logger.warn?.(`[services-registry] touch failed: ${err?.message ?? String(err)}`);
|
|
5240
|
+
});
|
|
5241
|
+
}, hbIntervalMs);
|
|
5242
|
+
}
|
|
5243
|
+
try {
|
|
5244
|
+
while (!context.isStop() && !stopRequested && !context.__tasksRunnerStop) {
|
|
5245
|
+
if (!runningStopControlPromise) {
|
|
5246
|
+
const claimedStopTask = await claimNextRunnableTask(
|
|
4944
5247
|
context,
|
|
4945
5248
|
tasksTable,
|
|
4946
|
-
|
|
4947
|
-
claimedStopTask,
|
|
5249
|
+
target,
|
|
4948
5250
|
registry,
|
|
4949
|
-
|
|
4950
|
-
|
|
5251
|
+
10,
|
|
5252
|
+
["stopRunner", "stop"]
|
|
5253
|
+
);
|
|
5254
|
+
if (claimedStopTask) {
|
|
5255
|
+
runningStopControlPromise = executeClaimedTask(
|
|
5256
|
+
context,
|
|
5257
|
+
tasksTable,
|
|
5258
|
+
historyTable,
|
|
5259
|
+
claimedStopTask,
|
|
5260
|
+
registry,
|
|
5261
|
+
runningTaskInstances
|
|
5262
|
+
).then(async (outcome) => {
|
|
5263
|
+
if (outcome.stopRunnerRequested && !stopRequested) {
|
|
5264
|
+
stopRequested = true;
|
|
5265
|
+
stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
|
|
5266
|
+
context.__tasksRunnerStop = true;
|
|
5267
|
+
await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
|
|
5268
|
+
}
|
|
5269
|
+
}).finally(() => {
|
|
5270
|
+
runningStopControlPromise = null;
|
|
5271
|
+
});
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
while (runningPromises.size < maxParallel) {
|
|
5275
|
+
const claimed = await claimNextRunnableTask(
|
|
5276
|
+
context,
|
|
5277
|
+
tasksTable,
|
|
5278
|
+
target,
|
|
5279
|
+
registry,
|
|
5280
|
+
scanLimit,
|
|
5281
|
+
allowedTasks
|
|
5282
|
+
);
|
|
5283
|
+
if (!claimed) break;
|
|
5284
|
+
const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
|
|
4951
5285
|
if (outcome.stopRunnerRequested && !stopRequested) {
|
|
4952
5286
|
stopRequested = true;
|
|
4953
5287
|
stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
|
|
@@ -4955,54 +5289,46 @@ async function runTasksLoop(context, options) {
|
|
|
4955
5289
|
await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
|
|
4956
5290
|
}
|
|
4957
5291
|
}).finally(() => {
|
|
4958
|
-
|
|
5292
|
+
runningPromises.delete(p);
|
|
4959
5293
|
});
|
|
5294
|
+
runningPromises.add(p);
|
|
5295
|
+
}
|
|
5296
|
+
await sleepMs(pollMs);
|
|
5297
|
+
}
|
|
5298
|
+
if (context.isStop() && !stopRequested) {
|
|
5299
|
+
await signalRunningTasksStop(context, runningTaskInstances, 5e3);
|
|
5300
|
+
}
|
|
5301
|
+
if (runningPromises.size > 0) {
|
|
5302
|
+
if (stopRequested) {
|
|
5303
|
+
await Promise.race([
|
|
5304
|
+
Promise.allSettled(Array.from(runningPromises)),
|
|
5305
|
+
sleepMs(stopAllowanceMs).then(() => {
|
|
5306
|
+
context.logger.warn?.(
|
|
5307
|
+
`[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
|
|
5308
|
+
);
|
|
5309
|
+
})
|
|
5310
|
+
]);
|
|
5311
|
+
} else {
|
|
5312
|
+
await Promise.allSettled(Array.from(runningPromises));
|
|
4960
5313
|
}
|
|
4961
5314
|
}
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
target,
|
|
4967
|
-
registry,
|
|
4968
|
-
scanLimit,
|
|
4969
|
-
allowedTasks
|
|
4970
|
-
);
|
|
4971
|
-
if (!claimed) break;
|
|
4972
|
-
const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
|
|
4973
|
-
if (outcome.stopRunnerRequested && !stopRequested) {
|
|
4974
|
-
stopRequested = true;
|
|
4975
|
-
stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
|
|
4976
|
-
context.__tasksRunnerStop = true;
|
|
4977
|
-
await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
|
|
4978
|
-
}
|
|
4979
|
-
}).finally(() => {
|
|
4980
|
-
runningPromises.delete(p);
|
|
4981
|
-
});
|
|
4982
|
-
runningPromises.add(p);
|
|
5315
|
+
} finally {
|
|
5316
|
+
if (registryInterval) {
|
|
5317
|
+
clearInterval(registryInterval);
|
|
5318
|
+
registryInterval = null;
|
|
4983
5319
|
}
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
await Promise.race([
|
|
4992
|
-
Promise.allSettled(Array.from(runningPromises)),
|
|
4993
|
-
sleepMs(stopAllowanceMs).then(() => {
|
|
4994
|
-
context.logger.warn?.(
|
|
4995
|
-
`[tasks] stop allowance (${stopAllowanceMs}ms) reached; ${runningPromises.size} task(s) still running`
|
|
4996
|
-
);
|
|
4997
|
-
})
|
|
4998
|
-
]);
|
|
4999
|
-
} else {
|
|
5000
|
-
await Promise.allSettled(Array.from(runningPromises));
|
|
5320
|
+
if (registryReg) {
|
|
5321
|
+
await unregisterServicesRegistry(context, registryReg).catch((err) => {
|
|
5322
|
+
context.logger.warn?.(`[services-registry] unregister failed: ${err?.message ?? String(err)}`);
|
|
5323
|
+
});
|
|
5324
|
+
registryReg = null;
|
|
5325
|
+
delete context.servicesRegistry;
|
|
5326
|
+
delete context.runnerHeartbeat;
|
|
5001
5327
|
}
|
|
5002
5328
|
}
|
|
5003
5329
|
}
|
|
5004
5330
|
async function waitForTaskResult(context, taskId, options = {}) {
|
|
5005
|
-
const db =
|
|
5331
|
+
const db = getDb3(context);
|
|
5006
5332
|
const queue = options.queue ?? "tasks";
|
|
5007
5333
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
5008
5334
|
const pollMs = options.pollMs ?? 500;
|
|
@@ -5030,6 +5356,14 @@ var TasksManager = class _TasksManager {
|
|
|
5030
5356
|
scanLimit;
|
|
5031
5357
|
allowedTasks;
|
|
5032
5358
|
registry;
|
|
5359
|
+
runnerServiceGroup;
|
|
5360
|
+
runnerServiceName;
|
|
5361
|
+
runnerIdentityDir;
|
|
5362
|
+
runnerHeartbeatIntervalMs;
|
|
5363
|
+
runnerHeartbeatStaleMs;
|
|
5364
|
+
runnerGroupMaxInstances;
|
|
5365
|
+
runnerEnforceMaxInstances;
|
|
5366
|
+
runnerMetadata;
|
|
5033
5367
|
constructor(context, options = {}) {
|
|
5034
5368
|
this.context = context;
|
|
5035
5369
|
this.queue = options.queue ?? "tasks";
|
|
@@ -5040,6 +5374,14 @@ var TasksManager = class _TasksManager {
|
|
|
5040
5374
|
this.scanLimit = options.scanLimit ?? 100;
|
|
5041
5375
|
this.allowedTasks = normalizeAllowedTasks(options.allowedTasks);
|
|
5042
5376
|
this.registry = normalizeRegistry(options.registry);
|
|
5377
|
+
this.runnerServiceGroup = options.runnerServiceGroup;
|
|
5378
|
+
this.runnerServiceName = options.runnerServiceName;
|
|
5379
|
+
this.runnerIdentityDir = options.runnerIdentityDir;
|
|
5380
|
+
this.runnerHeartbeatIntervalMs = options.runnerHeartbeatIntervalMs;
|
|
5381
|
+
this.runnerHeartbeatStaleMs = options.runnerHeartbeatStaleMs;
|
|
5382
|
+
this.runnerGroupMaxInstances = options.runnerGroupMaxInstances;
|
|
5383
|
+
this.runnerEnforceMaxInstances = options.runnerEnforceMaxInstances;
|
|
5384
|
+
this.runnerMetadata = options.runnerMetadata;
|
|
5043
5385
|
}
|
|
5044
5386
|
static init(context, options = {}) {
|
|
5045
5387
|
const defs = {
|
|
@@ -5049,7 +5391,14 @@ var TasksManager = class _TasksManager {
|
|
|
5049
5391
|
pollMs: "number default 1000",
|
|
5050
5392
|
maxParallel: "number default 1",
|
|
5051
5393
|
scanLimit: "number default 100",
|
|
5052
|
-
allowedTasks: "string"
|
|
5394
|
+
allowedTasks: "string",
|
|
5395
|
+
runnerServiceGroup: "string",
|
|
5396
|
+
runnerServiceName: "string",
|
|
5397
|
+
runnerIdentityDir: "string default ./data/runner-identities",
|
|
5398
|
+
runnerHeartbeatIntervalMs: "number default 10000",
|
|
5399
|
+
runnerHeartbeatStaleMs: "number default 45000",
|
|
5400
|
+
runnerGroupMaxInstances: "number",
|
|
5401
|
+
runnerEnforceMaxInstances: "boolean default true"
|
|
5053
5402
|
};
|
|
5054
5403
|
const discovered = context.params.getAllForModule(defs);
|
|
5055
5404
|
const resolved = {
|
|
@@ -5060,6 +5409,13 @@ var TasksManager = class _TasksManager {
|
|
|
5060
5409
|
maxParallel: discovered.maxParallel,
|
|
5061
5410
|
scanLimit: discovered.scanLimit,
|
|
5062
5411
|
allowedTasks: discovered.allowedTasks,
|
|
5412
|
+
runnerServiceGroup: discovered.runnerServiceGroup,
|
|
5413
|
+
runnerServiceName: discovered.runnerServiceName,
|
|
5414
|
+
runnerIdentityDir: discovered.runnerIdentityDir,
|
|
5415
|
+
runnerHeartbeatIntervalMs: discovered.runnerHeartbeatIntervalMs,
|
|
5416
|
+
runnerHeartbeatStaleMs: discovered.runnerHeartbeatStaleMs,
|
|
5417
|
+
runnerGroupMaxInstances: discovered.runnerGroupMaxInstances,
|
|
5418
|
+
runnerEnforceMaxInstances: discovered.runnerEnforceMaxInstances,
|
|
5063
5419
|
...options
|
|
5064
5420
|
};
|
|
5065
5421
|
return new _TasksManager(context, resolved);
|
|
@@ -5078,7 +5434,15 @@ var TasksManager = class _TasksManager {
|
|
|
5078
5434
|
maxParallel: options.maxParallel ?? this.maxParallel,
|
|
5079
5435
|
scanLimit: options.scanLimit ?? this.scanLimit,
|
|
5080
5436
|
allowedTasks: options.allowedTasks ?? this.allowedTasks,
|
|
5081
|
-
registry: options.registry ?? this.registry
|
|
5437
|
+
registry: options.registry ?? this.registry,
|
|
5438
|
+
runnerServiceGroup: options.runnerServiceGroup ?? this.runnerServiceGroup,
|
|
5439
|
+
runnerServiceName: options.runnerServiceName ?? this.runnerServiceName,
|
|
5440
|
+
runnerIdentityDir: options.runnerIdentityDir ?? this.runnerIdentityDir,
|
|
5441
|
+
runnerHeartbeatIntervalMs: options.runnerHeartbeatIntervalMs ?? this.runnerHeartbeatIntervalMs,
|
|
5442
|
+
runnerHeartbeatStaleMs: options.runnerHeartbeatStaleMs ?? this.runnerHeartbeatStaleMs,
|
|
5443
|
+
runnerGroupMaxInstances: options.runnerGroupMaxInstances ?? this.runnerGroupMaxInstances,
|
|
5444
|
+
runnerEnforceMaxInstances: options.runnerEnforceMaxInstances ?? this.runnerEnforceMaxInstances,
|
|
5445
|
+
runnerMetadata: options.runnerMetadata ?? this.runnerMetadata
|
|
5082
5446
|
});
|
|
5083
5447
|
}
|
|
5084
5448
|
};
|
|
@@ -5132,13 +5496,19 @@ export {
|
|
|
5132
5496
|
createElement2 as h,
|
|
5133
5497
|
joiEdateType,
|
|
5134
5498
|
joiStringArrayType,
|
|
5499
|
+
listServicesRegistry as listAliveRunnerHeartbeats,
|
|
5500
|
+
listServicesRegistry,
|
|
5135
5501
|
listSources,
|
|
5136
5502
|
listTables,
|
|
5137
5503
|
load,
|
|
5138
5504
|
organizeFooterMessages,
|
|
5139
5505
|
queueToTableNames,
|
|
5506
|
+
registerInServicesRegistry,
|
|
5507
|
+
registerInServicesRegistry as registerRunnerHeartbeat,
|
|
5140
5508
|
runNodeTaskScript,
|
|
5141
5509
|
runTasksLoop,
|
|
5510
|
+
servicesRegistryTable as runnerHeartbeatsTable,
|
|
5511
|
+
servicesRegistryTable,
|
|
5142
5512
|
setupContext,
|
|
5143
5513
|
showListScreen,
|
|
5144
5514
|
showMenuScreen,
|
|
@@ -5146,6 +5516,11 @@ export {
|
|
|
5146
5516
|
showMultiColumnListWithPreviewScreen,
|
|
5147
5517
|
showScreen,
|
|
5148
5518
|
showWordGridScreen,
|
|
5519
|
+
touchServicesRegistry as touchRunnerHeartbeat,
|
|
5520
|
+
touchServicesRegistry,
|
|
5521
|
+
unregisterServicesRegistry as unregisterRunnerHeartbeat,
|
|
5522
|
+
unregisterServicesRegistry,
|
|
5523
|
+
updateServicesRegistryMetadata,
|
|
5149
5524
|
updateTaskProgress,
|
|
5150
5525
|
useCallback,
|
|
5151
5526
|
useEffect3 as useEffect,
|