bullpane 0.6.2 → 0.7.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/lua/queueStats.lua +26 -0
- package/dist/server.mjs +1222 -192
- package/migrations/mysql/0011_flow_maps.sql +53 -0
- package/migrations/sqlite/0004_flow_maps.sql +37 -0
- package/package.json +1 -1
- package/web/assets/FlowsPage-C1DrWzZu.css +1 -0
- package/web/assets/FlowsPage-Cp6TR7Wa.js +26 -0
- package/web/assets/{JobTreePage-CxJzFwh9.js → JobTreePage-CbwbeeRl.js} +2 -2
- package/web/assets/McpConsentPage-CI53cgZX.js +1 -0
- package/web/assets/index-BuIZl_Bc.css +1 -0
- package/web/assets/index-Dj9D1-XU.js +553 -0
- package/web/assets/style-J6Fkg5PC.js +23 -0
- package/web/index.html +2 -2
- package/web/assets/FlowsPage-CE4Fl11b.js +0 -6
- package/web/assets/McpConsentPage-CmZMfyo4.js +0 -1
- package/web/assets/flowLayout-GUFMVhrV.js +0 -23
- package/web/assets/index-C2gL_lbF.css +0 -1
- package/web/assets/index-CN2nADIL.js +0 -548
- /package/web/assets/{flowLayout-BnuhLJ6X.css → style-BnuhLJ6X.css} +0 -0
package/dist/server.mjs
CHANGED
|
@@ -201,6 +201,7 @@ var JOB_STATES = [
|
|
|
201
201
|
"waiting-children"
|
|
202
202
|
];
|
|
203
203
|
var jobStateSchema = z.enum(JOB_STATES);
|
|
204
|
+
var GROUP_WAITING_CAP = 1e3;
|
|
204
205
|
var GROUP_STATUSES = ["waiting", "limited", "maxed", "paused"];
|
|
205
206
|
var listSchedulersQuerySchema = z.object({
|
|
206
207
|
page: z.coerce.number().int().min(1).default(1),
|
|
@@ -237,6 +238,12 @@ var BULK_JOB_ACTIONS = ["retry", "remove", "promote"];
|
|
|
237
238
|
var bulkJobActionSchema = z.object({
|
|
238
239
|
jobIds: z.array(z.string().min(1).max(200)).min(1, "jobIds must not be empty").max(BULK_JOB_LIMIT, `at most ${BULK_JOB_LIMIT} job ids per call`)
|
|
239
240
|
});
|
|
241
|
+
var PROMOTE_MATCHING_LIMIT = 2e3;
|
|
242
|
+
var promoteMatchingSchema = z.object({
|
|
243
|
+
query: z.string().max(500).optional(),
|
|
244
|
+
groupId: z.string().min(1).max(200).optional(),
|
|
245
|
+
cursor: z.string().max(20).optional()
|
|
246
|
+
}).refine((v) => !!v.query?.trim() || v.groupId !== void 0, { message: "query or groupId is required", path: ["query"] });
|
|
240
247
|
var listJobsQuerySchema = z.object({
|
|
241
248
|
state: jobStateSchema.default("waiting"),
|
|
242
249
|
/** BullMQ Pro: restrict to a group's waiting list (state is ignored when set) */
|
|
@@ -376,6 +383,7 @@ var AUDIT_ACTIONS = [
|
|
|
376
383
|
"queue.drain",
|
|
377
384
|
"queue.obliterate",
|
|
378
385
|
"scheduler.remove",
|
|
386
|
+
"job.promote_matching",
|
|
379
387
|
// BullMQ Pro groups
|
|
380
388
|
"group.pause",
|
|
381
389
|
"group.resume",
|
|
@@ -443,6 +451,38 @@ var createFlowEdgeSchema = z.object({
|
|
|
443
451
|
to: z.string().min(1),
|
|
444
452
|
label: z.string().max(120).nullable().optional()
|
|
445
453
|
});
|
|
454
|
+
function flowMapNodeId(ref) {
|
|
455
|
+
return `${ref.connectionId}:${ref.queueName}`;
|
|
456
|
+
}
|
|
457
|
+
var flowMapNodeRefSchema = z.object({
|
|
458
|
+
connectionId: z.string().min(1).max(64),
|
|
459
|
+
queueName: z.string().min(1).max(255)
|
|
460
|
+
});
|
|
461
|
+
var createFlowMapSchema = z.object({
|
|
462
|
+
name: z.string().trim().min(1).max(80),
|
|
463
|
+
description: z.string().max(500).nullable().optional(),
|
|
464
|
+
parentId: z.string().nullable().optional()
|
|
465
|
+
});
|
|
466
|
+
var updateFlowMapSchema = z.object({
|
|
467
|
+
name: z.string().trim().min(1).max(80).optional(),
|
|
468
|
+
description: z.string().max(500).nullable().optional(),
|
|
469
|
+
/** null moves the map to the root */
|
|
470
|
+
parentId: z.string().nullable().optional(),
|
|
471
|
+
position: z.number().int().min(0).optional()
|
|
472
|
+
});
|
|
473
|
+
var addFlowMapNodeSchema = flowMapNodeRefSchema.extend({
|
|
474
|
+
x: z.number().finite().nullable().optional(),
|
|
475
|
+
y: z.number().finite().nullable().optional()
|
|
476
|
+
});
|
|
477
|
+
var saveFlowMapLayoutSchema = z.object({
|
|
478
|
+
positions: z.array(z.object({ nodeId: z.string().min(1).max(320), x: z.number().finite(), y: z.number().finite() })).max(2e3)
|
|
479
|
+
});
|
|
480
|
+
var createFlowMapEdgeSchema = z.object({
|
|
481
|
+
from: flowMapNodeRefSchema,
|
|
482
|
+
to: flowMapNodeRefSchema,
|
|
483
|
+
label: z.string().max(120).nullable().optional()
|
|
484
|
+
});
|
|
485
|
+
var updateFlowMapEdgeSchema = z.object({ label: z.string().max(120).nullable() });
|
|
446
486
|
var jobTreeQuerySchema = z.object({
|
|
447
487
|
/** hard cap on jobs read; the walk is breadth-first so the cap keeps the top of the tree */
|
|
448
488
|
maxNodes: z.coerce.number().int().min(1).max(500).default(200),
|
|
@@ -1049,6 +1089,7 @@ function proApiRequired(what, consequence) {
|
|
|
1049
1089
|
var SCAN_COUNT = 1e3;
|
|
1050
1090
|
var STATS_METRIC_POINTS = 60;
|
|
1051
1091
|
var BULK_CONCURRENCY = 8;
|
|
1092
|
+
var PROMOTE_MATCHING_MAX_CALLS = 50;
|
|
1052
1093
|
var DEFAULT_RATE_WINDOW_MINUTES = 60;
|
|
1053
1094
|
var FORCE_DISCOVERY_MIN_MS = 5e3;
|
|
1054
1095
|
var SETUP_CACHE_MS = 1e4;
|
|
@@ -1387,17 +1428,18 @@ var RedisInspector = class {
|
|
|
1387
1428
|
const withMetrics = opts.withMetrics ? 1 : 0;
|
|
1388
1429
|
const windowMinutes = opts.rateWindowMinutes ?? DEFAULT_RATE_WINDOW_MINUTES;
|
|
1389
1430
|
const since = Date.now() - windowMinutes * 6e4;
|
|
1431
|
+
const groupCap = Math.max(0, Math.trunc(opts.groupWaitingCap ?? 0));
|
|
1390
1432
|
let replies;
|
|
1391
1433
|
if (c instanceof Cluster2) {
|
|
1392
1434
|
replies = await Promise.all(
|
|
1393
1435
|
queueNames.map(
|
|
1394
|
-
(q) => callScript(c, "queueStats", [...this.statsKeys(q), withMetrics, STATS_METRIC_POINTS, since, queueKeyPrefix(this.config.prefix, q)]).catch(() => null)
|
|
1436
|
+
(q) => callScript(c, "queueStats", [...this.statsKeys(q), withMetrics, STATS_METRIC_POINTS, since, queueKeyPrefix(this.config.prefix, q), groupCap]).catch(() => null)
|
|
1395
1437
|
)
|
|
1396
1438
|
);
|
|
1397
1439
|
} else {
|
|
1398
1440
|
const pipeline = c.pipeline();
|
|
1399
1441
|
for (const q of queueNames) {
|
|
1400
|
-
pipelineScript(pipeline, "queueStats", [...this.statsKeys(q), withMetrics, STATS_METRIC_POINTS, since, queueKeyPrefix(this.config.prefix, q)]);
|
|
1442
|
+
pipelineScript(pipeline, "queueStats", [...this.statsKeys(q), withMetrics, STATS_METRIC_POINTS, since, queueKeyPrefix(this.config.prefix, q), groupCap]);
|
|
1401
1443
|
}
|
|
1402
1444
|
const results = await pipeline.exec() ?? [];
|
|
1403
1445
|
replies = results.map(([err, reply]) => err ? null : reply);
|
|
@@ -1405,7 +1447,7 @@ var RedisInspector = class {
|
|
|
1405
1447
|
queueNames.forEach((name, i) => {
|
|
1406
1448
|
const reply = replies[i];
|
|
1407
1449
|
if (reply === null || reply === void 0) return;
|
|
1408
|
-
out[name] = parseStats(reply, withMetrics === 1, windowMinutes);
|
|
1450
|
+
out[name] = parseStats(reply, withMetrics === 1, windowMinutes, groupCap > 0);
|
|
1409
1451
|
});
|
|
1410
1452
|
return out;
|
|
1411
1453
|
}
|
|
@@ -1550,6 +1592,45 @@ var RedisInspector = class {
|
|
|
1550
1592
|
skippedLargePayloads: asNumber(reply[4])
|
|
1551
1593
|
};
|
|
1552
1594
|
}
|
|
1595
|
+
/**
|
|
1596
|
+
* The search script over `delayed`, with payload previews cut to 0 bytes: only
|
|
1597
|
+
* the ids are needed. Runs slice after slice until `limit` matches or
|
|
1598
|
+
* PROMOTE_MATCHING_MAX_CALLS calls, so one request stays bounded on a state of
|
|
1599
|
+
* millions. Each slice is the same bounded call the search box makes.
|
|
1600
|
+
*/
|
|
1601
|
+
async scanDelayedIds(queueName, match, cursor, limit) {
|
|
1602
|
+
const c = await this.ensureConnected();
|
|
1603
|
+
const ids = [];
|
|
1604
|
+
let scanned = 0;
|
|
1605
|
+
let total = 0;
|
|
1606
|
+
let at = cursor;
|
|
1607
|
+
for (let call = 0; call < PROMOTE_MATCHING_MAX_CALLS && ids.length < limit; call++) {
|
|
1608
|
+
const reply = asArray(
|
|
1609
|
+
await callScript(c, "getJobsSearch", [
|
|
1610
|
+
stateKey(this.config.prefix, queueName, "delayed"),
|
|
1611
|
+
STATE_KEY.delayed.type,
|
|
1612
|
+
at,
|
|
1613
|
+
match.groupId && !match.query ? this.opts.groupScanPerCall : this.opts.maxScanPerCall,
|
|
1614
|
+
match.query.toLowerCase(),
|
|
1615
|
+
limit - ids.length,
|
|
1616
|
+
queueKeyPrefix(this.config.prefix, queueName),
|
|
1617
|
+
0,
|
|
1618
|
+
this.opts.searchFieldCapBytes,
|
|
1619
|
+
this.opts.searchByteBudget,
|
|
1620
|
+
match.groupId,
|
|
1621
|
+
GROUP_ID_FIELDS.join(","),
|
|
1622
|
+
...JOB_SUMMARY_FIELDS
|
|
1623
|
+
])
|
|
1624
|
+
);
|
|
1625
|
+
for (const row of asArray(reply[0])) ids.push(String(asArray(row)[0]));
|
|
1626
|
+
scanned += asNumber(reply[2]);
|
|
1627
|
+
total = asNumber(reply[3]);
|
|
1628
|
+
const next = asNumber(reply[1], -1);
|
|
1629
|
+
if (next < 0) return { ids, scanned, total, end: at + asNumber(reply[2]), exhausted: true };
|
|
1630
|
+
at = next;
|
|
1631
|
+
}
|
|
1632
|
+
return { ids, scanned, total, end: at, exhausted: false };
|
|
1633
|
+
}
|
|
1553
1634
|
async getJob(queueName, jobId) {
|
|
1554
1635
|
const c = await this.ensureConnected();
|
|
1555
1636
|
const p = queueKeyPrefix(this.config.prefix, queueName);
|
|
@@ -2071,6 +2152,30 @@ var RedisInspector = class {
|
|
|
2071
2152
|
}
|
|
2072
2153
|
return { action, ok, failed, requested: ids.length };
|
|
2073
2154
|
}
|
|
2155
|
+
async promoteMatching(queueName, match, opts) {
|
|
2156
|
+
const query = match.query?.trim() ?? "";
|
|
2157
|
+
const groupId = match.groupId ?? "";
|
|
2158
|
+
if (!query && !groupId) throw new Error("query_or_group_required");
|
|
2159
|
+
const cursor = Math.max(0, toInt(opts.cursor ?? "0", 0));
|
|
2160
|
+
const scan = await this.scanDelayedIds(queueName, { query, groupId }, cursor, Math.max(1, opts.limit));
|
|
2161
|
+
let promoted = 0;
|
|
2162
|
+
const failed = [];
|
|
2163
|
+
let failedCount = 0;
|
|
2164
|
+
const run = async (jobId) => {
|
|
2165
|
+
try {
|
|
2166
|
+
await this.promoteJob(queueName, jobId);
|
|
2167
|
+
promoted++;
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
failedCount++;
|
|
2170
|
+
if (failed.length < 20) failed.push({ jobId, reason: errorMessage(err) });
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
for (let i = 0; i < scan.ids.length; i += BULK_CONCURRENCY) {
|
|
2174
|
+
await Promise.all(scan.ids.slice(i, i + BULK_CONCURRENCY).map(run));
|
|
2175
|
+
}
|
|
2176
|
+
const next = scan.exhausted ? null : String(Math.max(0, scan.end - promoted));
|
|
2177
|
+
return { matched: scan.ids.length, promoted, failed, failedCount, scanned: scan.scanned, total: scan.total, nextCursor: next };
|
|
2178
|
+
}
|
|
2074
2179
|
/**
|
|
2075
2180
|
* Operator "discard": move an active job to failed with no retry. An
|
|
2076
2181
|
* UnrecoverableError is what bullmq's own moveToFailed treats as "do not
|
|
@@ -2154,7 +2259,7 @@ var RedisInspector = class {
|
|
|
2154
2259
|
await queue.deleteGroup(groupId);
|
|
2155
2260
|
}
|
|
2156
2261
|
};
|
|
2157
|
-
function parseStats(reply, withMetrics, windowMinutes) {
|
|
2262
|
+
function parseStats(reply, withMetrics, windowMinutes, withGroupWaiting = false) {
|
|
2158
2263
|
const r = asArray(reply);
|
|
2159
2264
|
const counts = { ...EMPTY_COUNTS };
|
|
2160
2265
|
STATE_ORDER.forEach((state, i) => {
|
|
@@ -2210,6 +2315,9 @@ function parseStats(reply, withMetrics, windowMinutes) {
|
|
|
2210
2315
|
if (withMetrics) {
|
|
2211
2316
|
stats.metrics = { completed: metricsCompleted, failed: metricsFailed };
|
|
2212
2317
|
}
|
|
2318
|
+
if (withGroupWaiting && stats.groupsCount > 0) {
|
|
2319
|
+
stats.groupWaiting = { jobs: asNumber(r[21]), groups: asNumber(r[22]) };
|
|
2320
|
+
}
|
|
2213
2321
|
return stats;
|
|
2214
2322
|
}
|
|
2215
2323
|
function parseWindowMetrics(reply, request, collectedAt) {
|
|
@@ -3570,6 +3678,42 @@ var PgInspector = class {
|
|
|
3570
3678
|
}
|
|
3571
3679
|
return { action, ok, failed, requested: ids.length };
|
|
3572
3680
|
}
|
|
3681
|
+
/** See Inspector.promoteMatching. No groups on Postgres, so a group matches nothing. */
|
|
3682
|
+
async promoteMatching(queueName, match, opts) {
|
|
3683
|
+
const query = match.query?.trim() ?? "";
|
|
3684
|
+
if (match.groupId !== void 0) return { matched: 0, promoted: 0, failed: [], failedCount: 0, scanned: 0, total: 0, nextCursor: null };
|
|
3685
|
+
if (!query) throw new Error("query_or_group_required");
|
|
3686
|
+
const limit = Math.max(1, opts.limit);
|
|
3687
|
+
const ids = [];
|
|
3688
|
+
let cursor = opts.cursor ?? "0";
|
|
3689
|
+
let scanned = 0;
|
|
3690
|
+
let total = 0;
|
|
3691
|
+
let end = Math.max(0, Math.trunc(Number(cursor)) || 0);
|
|
3692
|
+
for (let call = 0; call < 50 && cursor !== null && ids.length < limit; call++) {
|
|
3693
|
+
const page = await this.searchJobs(queueName, "delayed", query, { cursor, limit: limit - ids.length });
|
|
3694
|
+
ids.push(...page.jobs.map((j) => j.id));
|
|
3695
|
+
scanned += page.scanned;
|
|
3696
|
+
total = page.total;
|
|
3697
|
+
end += page.scanned;
|
|
3698
|
+
cursor = page.nextCursor;
|
|
3699
|
+
}
|
|
3700
|
+
let promoted = 0;
|
|
3701
|
+
let failedCount = 0;
|
|
3702
|
+
const failed = [];
|
|
3703
|
+
const run = async (jobId) => {
|
|
3704
|
+
try {
|
|
3705
|
+
await this.promoteJob(queueName, jobId);
|
|
3706
|
+
promoted++;
|
|
3707
|
+
} catch (err) {
|
|
3708
|
+
failedCount++;
|
|
3709
|
+
if (failed.length < 20) failed.push({ jobId, reason: errorMessage2(err) });
|
|
3710
|
+
}
|
|
3711
|
+
};
|
|
3712
|
+
for (let i = 0; i < ids.length; i += BULK_CONCURRENCY2) {
|
|
3713
|
+
await Promise.all(ids.slice(i, i + BULK_CONCURRENCY2).map(run));
|
|
3714
|
+
}
|
|
3715
|
+
return { matched: ids.length, promoted, failed, failedCount, scanned, total, nextCursor: cursor === null ? null : String(Math.max(0, end - promoted)) };
|
|
3716
|
+
}
|
|
3573
3717
|
/** Operator "discard": an active job straight to failed, no retry (see RedisInspector.discardJob). */
|
|
3574
3718
|
async discardJob(queueName, jobId) {
|
|
3575
3719
|
const job = await this.getBullJob(queueName, jobId);
|
|
@@ -3893,6 +4037,9 @@ __export(schema_mysql_exports, {
|
|
|
3893
4037
|
auditLog: () => auditLog,
|
|
3894
4038
|
connections: () => connections,
|
|
3895
4039
|
flowEdges: () => flowEdges,
|
|
4040
|
+
flowMapEdges: () => flowMapEdges,
|
|
4041
|
+
flowMapNodes: () => flowMapNodes,
|
|
4042
|
+
flowMaps: () => flowMaps,
|
|
3896
4043
|
folderQueues: () => folderQueues,
|
|
3897
4044
|
folders: () => folders,
|
|
3898
4045
|
hiddenQueues: () => hiddenQueues,
|
|
@@ -4089,6 +4236,48 @@ var flowEdges = mysqlTable(
|
|
|
4089
4236
|
},
|
|
4090
4237
|
(t) => [uniqueIndex("flow_edges_unique").on(t.connectionId, t.fromQueue, t.toQueue)]
|
|
4091
4238
|
);
|
|
4239
|
+
var flowMaps = mysqlTable(
|
|
4240
|
+
"flow_maps",
|
|
4241
|
+
{
|
|
4242
|
+
id: id(),
|
|
4243
|
+
name: varchar("name", { length: 80 }).notNull(),
|
|
4244
|
+
description: varchar("description", { length: 500 }),
|
|
4245
|
+
parentId: varchar("parent_id", { length: 36 }),
|
|
4246
|
+
position: int("position").notNull().default(0),
|
|
4247
|
+
createdAt: createdAt(),
|
|
4248
|
+
updatedAt: datetime("updated_at", { mode: "date", fsp: 3 }).notNull()
|
|
4249
|
+
},
|
|
4250
|
+
(t) => [index("flow_maps_parent_id_idx").on(t.parentId)]
|
|
4251
|
+
);
|
|
4252
|
+
var flowMapNodes = mysqlTable(
|
|
4253
|
+
"flow_map_nodes",
|
|
4254
|
+
{
|
|
4255
|
+
mapId: varchar("map_id", { length: 36 }).notNull(),
|
|
4256
|
+
connectionId: varchar("connection_id", { length: 36 }).notNull(),
|
|
4257
|
+
queueName: varchar("queue_name", { length: 255 }).notNull(),
|
|
4258
|
+
x: double("x"),
|
|
4259
|
+
y: double("y")
|
|
4260
|
+
},
|
|
4261
|
+
(t) => [primaryKey({ columns: [t.mapId, t.connectionId, t.queueName] }), index("flow_map_nodes_connection_idx").on(t.connectionId)]
|
|
4262
|
+
);
|
|
4263
|
+
var flowMapEdges = mysqlTable(
|
|
4264
|
+
"flow_map_edges",
|
|
4265
|
+
{
|
|
4266
|
+
id: id(),
|
|
4267
|
+
mapId: varchar("map_id", { length: 36 }).notNull(),
|
|
4268
|
+
fromConnectionId: varchar("from_connection_id", { length: 36 }).notNull(),
|
|
4269
|
+
fromQueue: varchar("from_queue", { length: 255 }).notNull(),
|
|
4270
|
+
toConnectionId: varchar("to_connection_id", { length: 36 }).notNull(),
|
|
4271
|
+
toQueue: varchar("to_queue", { length: 255 }).notNull(),
|
|
4272
|
+
label: varchar("label", { length: 120 }),
|
|
4273
|
+
createdAt: createdAt()
|
|
4274
|
+
},
|
|
4275
|
+
(t) => [
|
|
4276
|
+
uniqueIndex("flow_map_edges_unique").on(t.mapId, t.fromConnectionId, t.fromQueue, t.toConnectionId, t.toQueue),
|
|
4277
|
+
index("flow_map_edges_from_conn_idx").on(t.fromConnectionId),
|
|
4278
|
+
index("flow_map_edges_to_conn_idx").on(t.toConnectionId)
|
|
4279
|
+
]
|
|
4280
|
+
);
|
|
4092
4281
|
var mcpClients = mysqlTable(
|
|
4093
4282
|
"mcp_clients",
|
|
4094
4283
|
{
|
|
@@ -4144,6 +4333,9 @@ __export(schema_sqlite_exports, {
|
|
|
4144
4333
|
auditLog: () => auditLog2,
|
|
4145
4334
|
connections: () => connections2,
|
|
4146
4335
|
flowEdges: () => flowEdges2,
|
|
4336
|
+
flowMapEdges: () => flowMapEdges2,
|
|
4337
|
+
flowMapNodes: () => flowMapNodes2,
|
|
4338
|
+
flowMaps: () => flowMaps2,
|
|
4147
4339
|
folderQueues: () => folderQueues2,
|
|
4148
4340
|
folders: () => folders2,
|
|
4149
4341
|
hiddenQueues: () => hiddenQueues2,
|
|
@@ -4359,6 +4551,48 @@ var settings2 = sqliteTable("settings", {
|
|
|
4359
4551
|
key: text2("key").primaryKey(),
|
|
4360
4552
|
value: text2("value").notNull()
|
|
4361
4553
|
});
|
|
4554
|
+
var flowMaps2 = sqliteTable(
|
|
4555
|
+
"flow_maps",
|
|
4556
|
+
{
|
|
4557
|
+
id: id2(),
|
|
4558
|
+
name: text2("name").notNull(),
|
|
4559
|
+
description: text2("description"),
|
|
4560
|
+
parentId: text2("parent_id"),
|
|
4561
|
+
position: integer("position").notNull().default(0),
|
|
4562
|
+
createdAt: createdAt2(),
|
|
4563
|
+
updatedAt: timestamp("updated_at").notNull()
|
|
4564
|
+
},
|
|
4565
|
+
(t) => [index2("flow_maps_parent_id_idx").on(t.parentId)]
|
|
4566
|
+
);
|
|
4567
|
+
var flowMapNodes2 = sqliteTable(
|
|
4568
|
+
"flow_map_nodes",
|
|
4569
|
+
{
|
|
4570
|
+
mapId: text2("map_id").notNull(),
|
|
4571
|
+
connectionId: text2("connection_id").notNull(),
|
|
4572
|
+
queueName: text2("queue_name").notNull(),
|
|
4573
|
+
x: real("x"),
|
|
4574
|
+
y: real("y")
|
|
4575
|
+
},
|
|
4576
|
+
(t) => [primaryKey2({ columns: [t.mapId, t.connectionId, t.queueName] }), index2("flow_map_nodes_connection_idx").on(t.connectionId)]
|
|
4577
|
+
);
|
|
4578
|
+
var flowMapEdges2 = sqliteTable(
|
|
4579
|
+
"flow_map_edges",
|
|
4580
|
+
{
|
|
4581
|
+
id: id2(),
|
|
4582
|
+
mapId: text2("map_id").notNull(),
|
|
4583
|
+
fromConnectionId: text2("from_connection_id").notNull(),
|
|
4584
|
+
fromQueue: text2("from_queue").notNull(),
|
|
4585
|
+
toConnectionId: text2("to_connection_id").notNull(),
|
|
4586
|
+
toQueue: text2("to_queue").notNull(),
|
|
4587
|
+
label: text2("label"),
|
|
4588
|
+
createdAt: createdAt2()
|
|
4589
|
+
},
|
|
4590
|
+
(t) => [
|
|
4591
|
+
uniqueIndex2("flow_map_edges_unique").on(t.mapId, t.fromConnectionId, t.fromQueue, t.toConnectionId, t.toQueue),
|
|
4592
|
+
index2("flow_map_edges_from_conn_idx").on(t.fromConnectionId),
|
|
4593
|
+
index2("flow_map_edges_to_conn_idx").on(t.toConnectionId)
|
|
4594
|
+
]
|
|
4595
|
+
);
|
|
4362
4596
|
|
|
4363
4597
|
// apps/server/src/db/schema.ts
|
|
4364
4598
|
var users3 = users;
|
|
@@ -4376,6 +4610,9 @@ var settings3 = settings;
|
|
|
4376
4610
|
var mcpClients3 = mcpClients;
|
|
4377
4611
|
var mcpAuthCodes3 = mcpAuthCodes;
|
|
4378
4612
|
var mcpGrants3 = mcpGrants;
|
|
4613
|
+
var flowMaps3 = flowMaps;
|
|
4614
|
+
var flowMapNodes3 = flowMapNodes;
|
|
4615
|
+
var flowMapEdges3 = flowMapEdges;
|
|
4379
4616
|
var current = "mysql";
|
|
4380
4617
|
function schemaDialect() {
|
|
4381
4618
|
return current;
|
|
@@ -4397,6 +4634,9 @@ function useSchema(dialect) {
|
|
|
4397
4634
|
mcpClients3 = s.mcpClients;
|
|
4398
4635
|
mcpAuthCodes3 = s.mcpAuthCodes;
|
|
4399
4636
|
mcpGrants3 = s.mcpGrants;
|
|
4637
|
+
flowMaps3 = s.flowMaps;
|
|
4638
|
+
flowMapNodes3 = s.flowMapNodes;
|
|
4639
|
+
flowMapEdges3 = s.flowMapEdges;
|
|
4400
4640
|
current = dialect;
|
|
4401
4641
|
}
|
|
4402
4642
|
|
|
@@ -5437,6 +5677,7 @@ var ROUTE_ACTIONS = {
|
|
|
5437
5677
|
"POST /api/connections/:id/queues/:queue/jobs/bulk/retry": "job.bulk_retry",
|
|
5438
5678
|
"POST /api/connections/:id/queues/:queue/jobs/bulk/remove": "job.bulk_remove",
|
|
5439
5679
|
"POST /api/connections/:id/queues/:queue/jobs/bulk/promote": "job.bulk_promote",
|
|
5680
|
+
"POST /api/connections/:id/queues/:queue/jobs/promote-matching": "job.promote_matching",
|
|
5440
5681
|
// queues
|
|
5441
5682
|
"POST /api/connections/:id/queues/:queue/pause": "queue.pause",
|
|
5442
5683
|
"POST /api/connections/:id/queues/:queue/resume": "queue.resume",
|
|
@@ -5477,7 +5718,17 @@ var UNAUDITED = /* @__PURE__ */ new Set([
|
|
|
5477
5718
|
"POST /api/connections/test",
|
|
5478
5719
|
"POST /api/setup",
|
|
5479
5720
|
"POST /api/alerts/:id/test",
|
|
5480
|
-
"POST /api/license/refresh"
|
|
5721
|
+
"POST /api/license/refresh",
|
|
5722
|
+
"POST /api/flow-maps",
|
|
5723
|
+
"PATCH /api/flow-maps/:id",
|
|
5724
|
+
"DELETE /api/flow-maps/:id",
|
|
5725
|
+
"POST /api/flow-maps/:id/nodes",
|
|
5726
|
+
"DELETE /api/flow-maps/:id/nodes/:nodeId",
|
|
5727
|
+
"PUT /api/flow-maps/:id/layout",
|
|
5728
|
+
"POST /api/flow-maps/:id/edges",
|
|
5729
|
+
"PATCH /api/flow-maps/:id/edges/:edgeId",
|
|
5730
|
+
"DELETE /api/flow-maps/:id/edges/:edgeId",
|
|
5731
|
+
"POST /api/flow-maps/:id/copy"
|
|
5481
5732
|
]);
|
|
5482
5733
|
function actionFor(method, routePattern) {
|
|
5483
5734
|
if (!routePattern) return null;
|
|
@@ -6539,9 +6790,213 @@ async function connectionRoutes(app) {
|
|
|
6539
6790
|
});
|
|
6540
6791
|
}
|
|
6541
6792
|
|
|
6793
|
+
// apps/server/src/ee/routes/flowMaps.ts
|
|
6794
|
+
var copyFlowMapSchema = createFlowMapSchema.omit({ description: true }).partial({ name: true });
|
|
6795
|
+
async function flowMapRoutes(app) {
|
|
6796
|
+
const gate = requireFeature("flows");
|
|
6797
|
+
const viewer = [requireAuth, gate, requireRole("viewer")];
|
|
6798
|
+
const operator = [requireAuth, gate, requireRole("operator")];
|
|
6799
|
+
app.get("/flow-maps", { preHandler: viewer }, async () => app.ctx.flowMaps.list());
|
|
6800
|
+
app.post("/flow-maps", { preHandler: operator }, async (request, reply) => {
|
|
6801
|
+
const map = await app.ctx.flowMaps.create(createFlowMapSchema.parse(request.body));
|
|
6802
|
+
reply.status(201);
|
|
6803
|
+
return map;
|
|
6804
|
+
});
|
|
6805
|
+
app.get("/flow-maps/:id", { preHandler: viewer }, async (request) => app.ctx.flowMaps.get(request.params.id));
|
|
6806
|
+
app.patch(
|
|
6807
|
+
"/flow-maps/:id",
|
|
6808
|
+
{ preHandler: operator },
|
|
6809
|
+
async (request) => app.ctx.flowMaps.update(request.params.id, updateFlowMapSchema.parse(request.body ?? {}))
|
|
6810
|
+
);
|
|
6811
|
+
app.delete("/flow-maps/:id", { preHandler: operator }, async (request) => {
|
|
6812
|
+
await app.ctx.flowMaps.remove(request.params.id);
|
|
6813
|
+
return { ok: true };
|
|
6814
|
+
});
|
|
6815
|
+
app.post(
|
|
6816
|
+
"/flow-maps/:id/nodes",
|
|
6817
|
+
{ preHandler: operator },
|
|
6818
|
+
async (request) => app.ctx.flowMaps.addNode(request.params.id, addFlowMapNodeSchema.parse(request.body))
|
|
6819
|
+
);
|
|
6820
|
+
app.delete(
|
|
6821
|
+
"/flow-maps/:id/nodes/:nodeId",
|
|
6822
|
+
{ preHandler: operator },
|
|
6823
|
+
async (request) => app.ctx.flowMaps.removeNode(request.params.id, request.params.nodeId)
|
|
6824
|
+
);
|
|
6825
|
+
app.put("/flow-maps/:id/layout", { preHandler: operator }, async (request) => {
|
|
6826
|
+
await app.ctx.flowMaps.saveLayout(request.params.id, saveFlowMapLayoutSchema.parse(request.body));
|
|
6827
|
+
return { ok: true };
|
|
6828
|
+
});
|
|
6829
|
+
app.post("/flow-maps/:id/edges", { preHandler: operator }, async (request, reply) => {
|
|
6830
|
+
const map = await app.ctx.flowMaps.addEdge(request.params.id, createFlowMapEdgeSchema.parse(request.body));
|
|
6831
|
+
reply.status(201);
|
|
6832
|
+
return map;
|
|
6833
|
+
});
|
|
6834
|
+
app.patch(
|
|
6835
|
+
"/flow-maps/:id/edges/:edgeId",
|
|
6836
|
+
{ preHandler: operator },
|
|
6837
|
+
async (request) => app.ctx.flowMaps.updateEdge(request.params.id, request.params.edgeId, updateFlowMapEdgeSchema.parse(request.body))
|
|
6838
|
+
);
|
|
6839
|
+
app.delete(
|
|
6840
|
+
"/flow-maps/:id/edges/:edgeId",
|
|
6841
|
+
{ preHandler: operator },
|
|
6842
|
+
async (request) => app.ctx.flowMaps.removeEdge(request.params.id, request.params.edgeId)
|
|
6843
|
+
);
|
|
6844
|
+
app.post("/flow-maps/:id/copy", { preHandler: operator }, async (request, reply) => {
|
|
6845
|
+
const map = await app.ctx.flowMaps.copy(request.params.id, copyFlowMapSchema.parse(request.body ?? {}));
|
|
6846
|
+
reply.status(201);
|
|
6847
|
+
return map;
|
|
6848
|
+
});
|
|
6849
|
+
}
|
|
6850
|
+
|
|
6542
6851
|
// apps/server/src/ee/routes/flows.ts
|
|
6543
6852
|
import { z as z4 } from "zod";
|
|
6544
|
-
|
|
6853
|
+
|
|
6854
|
+
// apps/server/src/ee/services/flows.ts
|
|
6855
|
+
import { and as and3, eq as eq3 } from "drizzle-orm";
|
|
6856
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
6857
|
+
var FLOW_CACHE_TTL_MS = 3e4;
|
|
6858
|
+
var FLOW_CONCURRENCY = 5;
|
|
6859
|
+
var DEFAULT_FLOW_SAMPLE = 200;
|
|
6860
|
+
function detectedEdgeId(from, to) {
|
|
6861
|
+
return `d:${from}->${to}`;
|
|
6862
|
+
}
|
|
6863
|
+
function toFlowEdgeDto(row) {
|
|
6864
|
+
return { id: row.id, from: row.fromQueue, to: row.toQueue, source: "manual", evidence: 0, label: row.label ?? null };
|
|
6865
|
+
}
|
|
6866
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
6867
|
+
const results = new Array(items.length);
|
|
6868
|
+
let next = 0;
|
|
6869
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
6870
|
+
while (next < items.length) {
|
|
6871
|
+
const index3 = next++;
|
|
6872
|
+
results[index3] = await fn(items[index3]);
|
|
6873
|
+
}
|
|
6874
|
+
});
|
|
6875
|
+
await Promise.all(workers);
|
|
6876
|
+
return results;
|
|
6877
|
+
}
|
|
6878
|
+
var FlowsService = class {
|
|
6879
|
+
constructor(db, connections5) {
|
|
6880
|
+
this.db = db;
|
|
6881
|
+
this.connections = connections5;
|
|
6882
|
+
}
|
|
6883
|
+
cache = /* @__PURE__ */ new Map();
|
|
6884
|
+
async getGraph(connectionId, sample) {
|
|
6885
|
+
const row = await this.connections.getRow(connectionId);
|
|
6886
|
+
const [detected, manual] = await Promise.all([this.detected(row, sample), this.manualEdges(connectionId)]);
|
|
6887
|
+
const nodeIds = new Set(detected.nodes.map((n) => n.id));
|
|
6888
|
+
const nodes = [...detected.nodes];
|
|
6889
|
+
for (const edge of manual) {
|
|
6890
|
+
for (const name of [edge.from, edge.to]) {
|
|
6891
|
+
if (!nodeIds.has(name)) {
|
|
6892
|
+
nodeIds.add(name);
|
|
6893
|
+
nodes.push({ id: name, queueName: name, counts: { ...EMPTY_COUNTS }, isPaused: false });
|
|
6894
|
+
}
|
|
6895
|
+
}
|
|
6896
|
+
}
|
|
6897
|
+
return {
|
|
6898
|
+
connectionId,
|
|
6899
|
+
nodes,
|
|
6900
|
+
edges: [...detected.edges, ...manual],
|
|
6901
|
+
sampledJobs: detected.sampledJobs
|
|
6902
|
+
};
|
|
6903
|
+
}
|
|
6904
|
+
/**
|
|
6905
|
+
* The detected (FlowProducer) edges of one connection, from the same 30 s
|
|
6906
|
+
* cache as getGraph, so flow maps and the /flows page share one sample.
|
|
6907
|
+
* Edge ends are queue names. Rejects when the connection is down.
|
|
6908
|
+
*/
|
|
6909
|
+
async detectedEdges(row, sample = DEFAULT_FLOW_SAMPLE) {
|
|
6910
|
+
return (await this.detected(row, sample)).edges;
|
|
6911
|
+
}
|
|
6912
|
+
invalidate(connectionId) {
|
|
6913
|
+
this.cache.delete(connectionId);
|
|
6914
|
+
}
|
|
6915
|
+
detected(row, sample) {
|
|
6916
|
+
const cached = this.cache.get(row.id);
|
|
6917
|
+
const now = Date.now();
|
|
6918
|
+
if (cached && cached.sample === sample && now - cached.at < FLOW_CACHE_TTL_MS) return cached.value;
|
|
6919
|
+
const value = withRedis(() => this.computeDetected(this.connections.inspectorFor(row), sample));
|
|
6920
|
+
this.cache.set(row.id, { at: now, sample, value });
|
|
6921
|
+
value.catch(() => this.cache.delete(row.id));
|
|
6922
|
+
return value;
|
|
6923
|
+
}
|
|
6924
|
+
async computeDetected(inspector, sample) {
|
|
6925
|
+
const names = await inspector.discoverQueues();
|
|
6926
|
+
if (names.length === 0) return { nodes: [], edges: [], sampledJobs: 0 };
|
|
6927
|
+
const stats = await inspector.getQueueStats(names);
|
|
6928
|
+
const nodes = names.map((name) => ({
|
|
6929
|
+
id: name,
|
|
6930
|
+
queueName: name,
|
|
6931
|
+
counts: stats[name]?.counts ?? { ...EMPTY_COUNTS },
|
|
6932
|
+
isPaused: stats[name]?.isPaused ?? false
|
|
6933
|
+
}));
|
|
6934
|
+
const samples = await mapWithConcurrency(
|
|
6935
|
+
names,
|
|
6936
|
+
FLOW_CONCURRENCY,
|
|
6937
|
+
(name) => inspector.sampleFlowEdges(name, { sample })
|
|
6938
|
+
);
|
|
6939
|
+
const edgeMap = /* @__PURE__ */ new Map();
|
|
6940
|
+
let sampledJobs = 0;
|
|
6941
|
+
for (const result of samples) {
|
|
6942
|
+
sampledJobs += result.sampled;
|
|
6943
|
+
for (const e of result.edges) {
|
|
6944
|
+
const id3 = detectedEdgeId(e.childQueue, e.parentQueue);
|
|
6945
|
+
const existing = edgeMap.get(id3);
|
|
6946
|
+
if (existing) {
|
|
6947
|
+
existing.evidence += e.count;
|
|
6948
|
+
} else {
|
|
6949
|
+
edgeMap.set(id3, {
|
|
6950
|
+
id: id3,
|
|
6951
|
+
from: e.childQueue,
|
|
6952
|
+
to: e.parentQueue,
|
|
6953
|
+
source: "detected",
|
|
6954
|
+
evidence: e.count,
|
|
6955
|
+
label: null
|
|
6956
|
+
});
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
return { nodes, edges: [...edgeMap.values()], sampledJobs };
|
|
6961
|
+
}
|
|
6962
|
+
async manualEdges(connectionId) {
|
|
6963
|
+
const rows = await this.db.select().from(flowEdges3).where(eq3(flowEdges3.connectionId, connectionId));
|
|
6964
|
+
return rows.map(toFlowEdgeDto);
|
|
6965
|
+
}
|
|
6966
|
+
async createManualEdge(input) {
|
|
6967
|
+
await this.connections.getRow(input.connectionId);
|
|
6968
|
+
if (input.from === input.to) throw conflict("A queue cannot flow into itself");
|
|
6969
|
+
const existing = await this.db.select({ id: flowEdges3.id }).from(flowEdges3).where(
|
|
6970
|
+
and3(
|
|
6971
|
+
eq3(flowEdges3.connectionId, input.connectionId),
|
|
6972
|
+
eq3(flowEdges3.fromQueue, input.from),
|
|
6973
|
+
eq3(flowEdges3.toQueue, input.to)
|
|
6974
|
+
)
|
|
6975
|
+
).limit(1);
|
|
6976
|
+
if (existing[0]) throw conflict("This edge already exists");
|
|
6977
|
+
const id3 = nanoid2();
|
|
6978
|
+
await this.db.insert(flowEdges3).values({
|
|
6979
|
+
id: id3,
|
|
6980
|
+
connectionId: input.connectionId,
|
|
6981
|
+
fromQueue: input.from,
|
|
6982
|
+
toQueue: input.to,
|
|
6983
|
+
label: input.label ?? null,
|
|
6984
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
6985
|
+
});
|
|
6986
|
+
const rows = await this.db.select().from(flowEdges3).where(eq3(flowEdges3.id, id3)).limit(1);
|
|
6987
|
+
const row = rows[0];
|
|
6988
|
+
if (!row) throw notFound("Flow edge");
|
|
6989
|
+
return toFlowEdgeDto(row);
|
|
6990
|
+
}
|
|
6991
|
+
async deleteManualEdge(id3) {
|
|
6992
|
+
const rows = await this.db.select({ id: flowEdges3.id }).from(flowEdges3).where(eq3(flowEdges3.id, id3)).limit(1);
|
|
6993
|
+
if (!rows[0]) throw notFound("Flow edge");
|
|
6994
|
+
await this.db.delete(flowEdges3).where(eq3(flowEdges3.id, id3));
|
|
6995
|
+
}
|
|
6996
|
+
};
|
|
6997
|
+
|
|
6998
|
+
// apps/server/src/ee/routes/flows.ts
|
|
6999
|
+
var flowsQuerySchema = z4.object({ sample: z4.coerce.number().int().min(10).max(2e3).default(DEFAULT_FLOW_SAMPLE) });
|
|
6545
7000
|
async function flowRoutes(app) {
|
|
6546
7001
|
const gate = requireFeature("flows");
|
|
6547
7002
|
const viewer = [requireAuth, gate, requireRole("viewer")];
|
|
@@ -6708,6 +7163,26 @@ async function jobRoutes(app) {
|
|
|
6708
7163
|
return result;
|
|
6709
7164
|
});
|
|
6710
7165
|
}
|
|
7166
|
+
app.post(`${base}/promote-matching`, { preHandler: [operator] }, async (request) => {
|
|
7167
|
+
const input = promoteMatchingSchema.parse(request.body ?? {});
|
|
7168
|
+
const inspector = await app.ctx.connections.getInspector(request.params.id);
|
|
7169
|
+
const result = await withRedis(
|
|
7170
|
+
() => inspector.promoteMatching(request.params.queue, { query: input.query, groupId: input.groupId }, { cursor: input.cursor ?? null, limit: PROMOTE_MATCHING_LIMIT })
|
|
7171
|
+
);
|
|
7172
|
+
request.auditDetail({
|
|
7173
|
+
...input.groupId ? { groupId: input.groupId } : {},
|
|
7174
|
+
...input.query?.trim() ? { query: input.query.trim().slice(0, 100) } : {},
|
|
7175
|
+
matched: result.matched,
|
|
7176
|
+
promoted: result.promoted,
|
|
7177
|
+
failed: result.failedCount,
|
|
7178
|
+
...result.failed.length > 0 ? { reasons: result.failed.slice(0, 10).map((f) => `${f.jobId}: ${f.reason}`) } : {}
|
|
7179
|
+
});
|
|
7180
|
+
request.log.info(
|
|
7181
|
+
{ queue: request.params.queue, groupId: input.groupId, matched: result.matched, promoted: result.promoted, failed: result.failedCount, by: request.user?.id },
|
|
7182
|
+
"promoted matching delayed jobs"
|
|
7183
|
+
);
|
|
7184
|
+
return result;
|
|
7185
|
+
});
|
|
6711
7186
|
}
|
|
6712
7187
|
|
|
6713
7188
|
// apps/server/src/routes/groups.ts
|
|
@@ -6809,7 +7284,7 @@ async function queueRoutes(app) {
|
|
|
6809
7284
|
const { id: id3, queue } = request.params;
|
|
6810
7285
|
const row = await app.ctx.connections.getRow(id3);
|
|
6811
7286
|
const inspector = app.ctx.connections.inspectorFor(row);
|
|
6812
|
-
const stats = await withRedis(() => inspector.getQueueStats([queue], { withMetrics: true }));
|
|
7287
|
+
const stats = await withRedis(() => inspector.getQueueStats([queue], { withMetrics: true, groupWaitingCap: GROUP_WAITING_CAP }));
|
|
6813
7288
|
const s = stats[queue];
|
|
6814
7289
|
if (!s) throw notFound("Queue");
|
|
6815
7290
|
const summary = {
|
|
@@ -6824,6 +7299,7 @@ async function queueRoutes(app) {
|
|
|
6824
7299
|
rates: s.rates
|
|
6825
7300
|
};
|
|
6826
7301
|
if (s.metrics) summary.metrics = s.metrics;
|
|
7302
|
+
if (s.groupWaiting) summary.groupWaiting = { jobs: s.groupWaiting.jobs, complete: s.groupWaiting.groups >= s.groupsCount };
|
|
6827
7303
|
return summary;
|
|
6828
7304
|
});
|
|
6829
7305
|
app.get(`${base}/setup`, { preHandler: [viewer] }, async (request) => {
|
|
@@ -7014,7 +7490,7 @@ async function ssoRoutes(app) {
|
|
|
7014
7490
|
|
|
7015
7491
|
// apps/server/src/ee/mcp/service.ts
|
|
7016
7492
|
import { createHash as createHash2, createHmac, hkdfSync as hkdfSync2, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
7017
|
-
import { nanoid as
|
|
7493
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
7018
7494
|
var MCP_SETTINGS_KEY = "mcp.max_access";
|
|
7019
7495
|
var MCP_SCOPES = { read: "queues:read", write: "queues:write" };
|
|
7020
7496
|
var ACCESS_TTL_S = 60 * 60;
|
|
@@ -7297,7 +7773,7 @@ var McpService = class {
|
|
|
7297
7773
|
const challenge = b64url(createHash2("sha256").update(p.code_verifier).digest());
|
|
7298
7774
|
if (!safeEqual2(challenge, row.codeChallenge)) throw new OAuthError(400, "invalid_grant", "PKCE verification failed");
|
|
7299
7775
|
const refresh = random("bprt_");
|
|
7300
|
-
const grantId =
|
|
7776
|
+
const grantId = nanoid3();
|
|
7301
7777
|
await this.store.insertGrant({
|
|
7302
7778
|
id: grantId,
|
|
7303
7779
|
clientId: row.clientId,
|
|
@@ -7520,6 +7996,7 @@ async function apiPlugin(app) {
|
|
|
7520
7996
|
await app.register(jobRoutes);
|
|
7521
7997
|
await app.register(groupRoutes);
|
|
7522
7998
|
await app.register(flowRoutes);
|
|
7999
|
+
await app.register(flowMapRoutes);
|
|
7523
8000
|
await app.register(folderRoutes);
|
|
7524
8001
|
await app.register(alertRoutes);
|
|
7525
8002
|
await app.register(userRoutes);
|
|
@@ -7529,8 +8006,8 @@ async function apiPlugin(app) {
|
|
|
7529
8006
|
}
|
|
7530
8007
|
|
|
7531
8008
|
// apps/server/src/ee/services/audit.ts
|
|
7532
|
-
import { and as
|
|
7533
|
-
import { nanoid as
|
|
8009
|
+
import { and as and4, asc, desc as desc2, eq as eq4, gte, lt as lt3, lte, or, sql } from "drizzle-orm";
|
|
8010
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
7534
8011
|
var MAX_ERROR = 500;
|
|
7535
8012
|
var MAX_UA = 255;
|
|
7536
8013
|
var MAX_JOB_ID = 255;
|
|
@@ -7614,7 +8091,7 @@ var AuditService = class {
|
|
|
7614
8091
|
async record(input) {
|
|
7615
8092
|
try {
|
|
7616
8093
|
await this.db.insert(auditLog3).values({
|
|
7617
|
-
id:
|
|
8094
|
+
id: nanoid4(),
|
|
7618
8095
|
createdAt: input.createdAt ?? /* @__PURE__ */ new Date(),
|
|
7619
8096
|
actorId: input.actor?.id ?? null,
|
|
7620
8097
|
// Denormalised on purpose: the row must still name the person after the
|
|
@@ -7646,22 +8123,22 @@ var AuditService = class {
|
|
|
7646
8123
|
*/
|
|
7647
8124
|
async list(query) {
|
|
7648
8125
|
const filters = [];
|
|
7649
|
-
if (query.actorId) filters.push(
|
|
7650
|
-
if (query.action) filters.push(
|
|
7651
|
-
if (query.connectionId) filters.push(
|
|
7652
|
-
if (query.queueName) filters.push(
|
|
7653
|
-
if (query.jobId) filters.push(
|
|
7654
|
-
if (query.result) filters.push(
|
|
8126
|
+
if (query.actorId) filters.push(eq4(auditLog3.actorId, query.actorId));
|
|
8127
|
+
if (query.action) filters.push(eq4(auditLog3.action, query.action));
|
|
8128
|
+
if (query.connectionId) filters.push(eq4(auditLog3.connectionId, query.connectionId));
|
|
8129
|
+
if (query.queueName) filters.push(eq4(auditLog3.queueName, query.queueName));
|
|
8130
|
+
if (query.jobId) filters.push(eq4(auditLog3.jobId, query.jobId));
|
|
8131
|
+
if (query.result) filters.push(eq4(auditLog3.result, query.result));
|
|
7655
8132
|
if (query.from) filters.push(gte(auditLog3.createdAt, new Date(query.from)));
|
|
7656
8133
|
if (query.to) filters.push(lt3(auditLog3.createdAt, new Date(query.to)));
|
|
7657
8134
|
if (query.cursor) {
|
|
7658
8135
|
const c = decodeCursor(query.cursor);
|
|
7659
8136
|
if (c) {
|
|
7660
|
-
const keyset = or(lt3(auditLog3.createdAt, c.createdAt),
|
|
8137
|
+
const keyset = or(lt3(auditLog3.createdAt, c.createdAt), and4(eq4(auditLog3.createdAt, c.createdAt), lt3(auditLog3.id, c.id)));
|
|
7661
8138
|
if (keyset) filters.push(keyset);
|
|
7662
8139
|
}
|
|
7663
8140
|
}
|
|
7664
|
-
const where = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] :
|
|
8141
|
+
const where = filters.length === 0 ? void 0 : filters.length === 1 ? filters[0] : and4(...filters);
|
|
7665
8142
|
const rows = await this.db.select().from(auditLog3).where(where).orderBy(desc2(auditLog3.createdAt), desc2(auditLog3.id)).limit(query.limit + 1);
|
|
7666
8143
|
const page = rows.slice(0, query.limit);
|
|
7667
8144
|
const last = page[page.length - 1];
|
|
@@ -7726,8 +8203,8 @@ var AuditService = class {
|
|
|
7726
8203
|
};
|
|
7727
8204
|
|
|
7728
8205
|
// apps/server/src/services/connections.ts
|
|
7729
|
-
import { and as
|
|
7730
|
-
import { nanoid as
|
|
8206
|
+
import { and as and5, eq as eq5, inArray, or as or2 } from "drizzle-orm";
|
|
8207
|
+
import { nanoid as nanoid5 } from "nanoid";
|
|
7731
8208
|
var STATUS_TTL_MS = 1e4;
|
|
7732
8209
|
var SCHEDULER_FANOUT_CONCURRENCY = 6;
|
|
7733
8210
|
var SCHEDULERS_PER_QUEUE_CAP = 500;
|
|
@@ -7780,7 +8257,7 @@ var ConnectionsService = class {
|
|
|
7780
8257
|
return this.db.select().from(connections3).orderBy(connections3.position, connections3.createdAt);
|
|
7781
8258
|
}
|
|
7782
8259
|
async getRow(id3) {
|
|
7783
|
-
const rows = await this.db.select().from(connections3).where(
|
|
8260
|
+
const rows = await this.db.select().from(connections3).where(eq5(connections3.id, id3)).limit(1);
|
|
7784
8261
|
const row = rows[0];
|
|
7785
8262
|
if (!row) throw notFound("Connection");
|
|
7786
8263
|
return row;
|
|
@@ -7795,7 +8272,7 @@ var ConnectionsService = class {
|
|
|
7795
8272
|
return Promise.all(rows.map(async (row) => toConnectionDto(row, await this.getStatus(row))));
|
|
7796
8273
|
}
|
|
7797
8274
|
async create(input) {
|
|
7798
|
-
const id3 =
|
|
8275
|
+
const id3 = nanoid5();
|
|
7799
8276
|
const siblings = await this.db.select({ id: connections3.id }).from(connections3);
|
|
7800
8277
|
await this.db.insert(connections3).values({
|
|
7801
8278
|
id: id3,
|
|
@@ -7832,7 +8309,7 @@ var ConnectionsService = class {
|
|
|
7832
8309
|
const ordered = [...ids, ...rows.filter((r) => !ids.includes(r.id)).map((r) => r.id)];
|
|
7833
8310
|
await this.db.transaction(async (tx) => {
|
|
7834
8311
|
for (const [index3, id3] of ordered.entries()) {
|
|
7835
|
-
await tx.update(connections3).set({ position: index3 }).where(
|
|
8312
|
+
await tx.update(connections3).set({ position: index3 }).where(eq5(connections3.id, id3));
|
|
7836
8313
|
}
|
|
7837
8314
|
});
|
|
7838
8315
|
return this.list();
|
|
@@ -7848,7 +8325,7 @@ var ConnectionsService = class {
|
|
|
7848
8325
|
if (input.cluster !== void 0) patch.cluster = input.cluster;
|
|
7849
8326
|
if (input.queueFilter !== void 0) patch.queueFilter = input.queueFilter;
|
|
7850
8327
|
if (Object.keys(patch).length > 0) {
|
|
7851
|
-
await this.db.update(connections3).set(patch).where(
|
|
8328
|
+
await this.db.update(connections3).set(patch).where(eq5(connections3.id, id3));
|
|
7852
8329
|
}
|
|
7853
8330
|
await this.pool.evict(id3);
|
|
7854
8331
|
this.notifyEvict(id3);
|
|
@@ -7861,11 +8338,13 @@ var ConnectionsService = class {
|
|
|
7861
8338
|
await this.pool.evict(id3);
|
|
7862
8339
|
this.notifyEvict(id3);
|
|
7863
8340
|
this.statusCache.delete(id3);
|
|
7864
|
-
await this.db.delete(folderQueues3).where(
|
|
7865
|
-
await this.db.delete(hiddenQueues3).where(
|
|
7866
|
-
await this.db.delete(alerts3).where(
|
|
7867
|
-
await this.db.delete(flowEdges3).where(
|
|
7868
|
-
await this.db.delete(
|
|
8341
|
+
await this.db.delete(folderQueues3).where(eq5(folderQueues3.connectionId, id3));
|
|
8342
|
+
await this.db.delete(hiddenQueues3).where(eq5(hiddenQueues3.connectionId, id3));
|
|
8343
|
+
await this.db.delete(alerts3).where(eq5(alerts3.connectionId, id3));
|
|
8344
|
+
await this.db.delete(flowEdges3).where(eq5(flowEdges3.connectionId, id3));
|
|
8345
|
+
await this.db.delete(flowMapEdges3).where(or2(eq5(flowMapEdges3.fromConnectionId, id3), eq5(flowMapEdges3.toConnectionId, id3)));
|
|
8346
|
+
await this.db.delete(flowMapNodes3).where(eq5(flowMapNodes3.connectionId, id3));
|
|
8347
|
+
await this.db.delete(connections3).where(eq5(connections3.id, id3));
|
|
7869
8348
|
}
|
|
7870
8349
|
inspectorFor(row) {
|
|
7871
8350
|
return this.pool.get({
|
|
@@ -7922,12 +8401,12 @@ var ConnectionsService = class {
|
|
|
7922
8401
|
// -------------------------------------------------------------------------
|
|
7923
8402
|
/** Names hidden on this connection, as a Set for O(1) filtering. */
|
|
7924
8403
|
async hiddenQueueNames(connectionId) {
|
|
7925
|
-
const rows = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(
|
|
8404
|
+
const rows = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(eq5(hiddenQueues3.connectionId, connectionId));
|
|
7926
8405
|
return new Set(rows.map((r) => r.queueName));
|
|
7927
8406
|
}
|
|
7928
8407
|
/** The hidden list for the UI, newest first, with the display name of who hid it. */
|
|
7929
8408
|
async listHiddenQueues(connectionId) {
|
|
7930
|
-
const rows = await this.db.select().from(hiddenQueues3).where(
|
|
8409
|
+
const rows = await this.db.select().from(hiddenQueues3).where(eq5(hiddenQueues3.connectionId, connectionId)).orderBy(hiddenQueues3.hiddenAt);
|
|
7931
8410
|
const ids = [...new Set(rows.map((r) => r.hiddenBy).filter((v) => !!v))];
|
|
7932
8411
|
const names = /* @__PURE__ */ new Map();
|
|
7933
8412
|
if (ids.length > 0) {
|
|
@@ -7946,7 +8425,7 @@ var ConnectionsService = class {
|
|
|
7946
8425
|
/** Idempotent: hiding an already hidden queue is a no-op, not a 409. */
|
|
7947
8426
|
async hideQueue(connectionId, queueName, userId) {
|
|
7948
8427
|
await this.getRow(connectionId);
|
|
7949
|
-
const existing = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(
|
|
8428
|
+
const existing = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(and5(eq5(hiddenQueues3.connectionId, connectionId), eq5(hiddenQueues3.queueName, queueName))).limit(1);
|
|
7950
8429
|
if (existing.length === 0) {
|
|
7951
8430
|
await this.db.insert(hiddenQueues3).values({ connectionId, queueName, hiddenAt: /* @__PURE__ */ new Date(), hiddenBy: userId });
|
|
7952
8431
|
}
|
|
@@ -7955,11 +8434,11 @@ var ConnectionsService = class {
|
|
|
7955
8434
|
/** Idempotent too: unhiding something that is not hidden succeeds. */
|
|
7956
8435
|
async unhideQueue(connectionId, queueName) {
|
|
7957
8436
|
await this.getRow(connectionId);
|
|
7958
|
-
await this.db.delete(hiddenQueues3).where(
|
|
8437
|
+
await this.db.delete(hiddenQueues3).where(and5(eq5(hiddenQueues3.connectionId, connectionId), eq5(hiddenQueues3.queueName, queueName)));
|
|
7959
8438
|
return this.listHiddenQueues(connectionId);
|
|
7960
8439
|
}
|
|
7961
8440
|
async isQueueHidden(connectionId, queueName) {
|
|
7962
|
-
const rows = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(
|
|
8441
|
+
const rows = await this.db.select({ queueName: hiddenQueues3.queueName }).from(hiddenQueues3).where(and5(eq5(hiddenQueues3.connectionId, connectionId), eq5(hiddenQueues3.queueName, queueName))).limit(1);
|
|
7963
8442
|
return rows.length > 0;
|
|
7964
8443
|
}
|
|
7965
8444
|
/**
|
|
@@ -8059,7 +8538,7 @@ var ConnectionsService = class {
|
|
|
8059
8538
|
}
|
|
8060
8539
|
/** Used by seeding: does a connection with this name exist? */
|
|
8061
8540
|
async findByName(name) {
|
|
8062
|
-
const rows = await this.db.select().from(connections3).where(
|
|
8541
|
+
const rows = await this.db.select().from(connections3).where(and5(eq5(connections3.name, name))).limit(1);
|
|
8063
8542
|
return rows[0] ?? null;
|
|
8064
8543
|
}
|
|
8065
8544
|
};
|
|
@@ -8597,13 +9076,13 @@ var AttentionService = class {
|
|
|
8597
9076
|
};
|
|
8598
9077
|
|
|
8599
9078
|
// apps/server/src/services/settings-store.ts
|
|
8600
|
-
import { eq as
|
|
9079
|
+
import { eq as eq6 } from "drizzle-orm";
|
|
8601
9080
|
var DrizzleSettingsStore = class {
|
|
8602
9081
|
constructor(db) {
|
|
8603
9082
|
this.db = db;
|
|
8604
9083
|
}
|
|
8605
9084
|
async get(key) {
|
|
8606
|
-
const rows = await this.db.select().from(settings3).where(
|
|
9085
|
+
const rows = await this.db.select().from(settings3).where(eq6(settings3.key, key)).limit(1);
|
|
8607
9086
|
const value = rows[0]?.value;
|
|
8608
9087
|
return value === void 0 || value === "" ? null : value;
|
|
8609
9088
|
}
|
|
@@ -8621,148 +9100,512 @@ var DrizzleSettingsStore = class {
|
|
|
8621
9100
|
await this.db.insert(settings3).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
|
|
8622
9101
|
}
|
|
8623
9102
|
async delete(key) {
|
|
8624
|
-
await this.db.delete(settings3).where(
|
|
9103
|
+
await this.db.delete(settings3).where(eq6(settings3.key, key));
|
|
8625
9104
|
}
|
|
8626
9105
|
};
|
|
8627
9106
|
|
|
8628
|
-
// apps/server/src/ee/services/
|
|
8629
|
-
import { and as
|
|
8630
|
-
import { nanoid as
|
|
8631
|
-
var
|
|
8632
|
-
var
|
|
8633
|
-
|
|
8634
|
-
|
|
9107
|
+
// apps/server/src/ee/services/flowMaps.ts
|
|
9108
|
+
import { and as and6, eq as eq7, inArray as inArray2, isNull as isNull2, or as or3 } from "drizzle-orm";
|
|
9109
|
+
import { nanoid as nanoid6 } from "nanoid";
|
|
9110
|
+
var DETECTED_MAP_PREFIX = "detected:";
|
|
9111
|
+
var DETECTED_WAIT_MS = 3e3;
|
|
9112
|
+
var NAME_MAX = 80;
|
|
9113
|
+
var detectedMapReadOnly = () => new HttpError(409, "detected_map_read_only", "Detected maps are computed from BullMQ flows and cannot be edited. Copy it to edit it.");
|
|
9114
|
+
function isDetectedMapId(id3) {
|
|
9115
|
+
return id3.startsWith(DETECTED_MAP_PREFIX);
|
|
9116
|
+
}
|
|
9117
|
+
function detectedMapId(connectionId, rootQueue) {
|
|
9118
|
+
return `${DETECTED_MAP_PREFIX}${connectionId}:${rootQueue}`;
|
|
9119
|
+
}
|
|
9120
|
+
function parseNodeId(nodeId) {
|
|
9121
|
+
const i = nodeId.indexOf(":");
|
|
9122
|
+
if (i <= 0 || i === nodeId.length - 1) return null;
|
|
9123
|
+
return { connectionId: nodeId.slice(0, i), queueName: nodeId.slice(i + 1) };
|
|
9124
|
+
}
|
|
9125
|
+
function detectedComponents(edges) {
|
|
9126
|
+
const parent = /* @__PURE__ */ new Map();
|
|
9127
|
+
const find = (q) => {
|
|
9128
|
+
let r = q;
|
|
9129
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
9130
|
+
let c = q;
|
|
9131
|
+
while (c !== r) {
|
|
9132
|
+
const next = parent.get(c);
|
|
9133
|
+
parent.set(c, r);
|
|
9134
|
+
c = next;
|
|
9135
|
+
}
|
|
9136
|
+
return r;
|
|
9137
|
+
};
|
|
9138
|
+
for (const e of edges) {
|
|
9139
|
+
for (const q of [e.from, e.to]) if (!parent.has(q)) parent.set(q, q);
|
|
9140
|
+
const a = find(e.from);
|
|
9141
|
+
const b = find(e.to);
|
|
9142
|
+
if (a !== b) parent.set(a, b);
|
|
9143
|
+
}
|
|
9144
|
+
const groups = /* @__PURE__ */ new Map();
|
|
9145
|
+
for (const q of parent.keys()) {
|
|
9146
|
+
const r = find(q);
|
|
9147
|
+
const g = groups.get(r) ?? { queues: [], edges: [] };
|
|
9148
|
+
g.queues.push(q);
|
|
9149
|
+
groups.set(r, g);
|
|
9150
|
+
}
|
|
9151
|
+
for (const e of edges) groups.get(find(e.from))?.edges.push(e);
|
|
9152
|
+
const out = [];
|
|
9153
|
+
for (const g of groups.values()) {
|
|
9154
|
+
if (g.queues.length < 2) continue;
|
|
9155
|
+
const outgoing = new Set(g.edges.map((e) => e.from));
|
|
9156
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
9157
|
+
for (const e of g.edges) incoming.set(e.to, (incoming.get(e.to) ?? 0) + e.evidence);
|
|
9158
|
+
const sinks = g.queues.filter((q) => !outgoing.has(q));
|
|
9159
|
+
const candidates = sinks.length > 0 ? sinks : g.queues;
|
|
9160
|
+
candidates.sort((a, b) => (incoming.get(b) ?? 0) - (incoming.get(a) ?? 0) || a.localeCompare(b));
|
|
9161
|
+
out.push({ root: candidates[0], queues: g.queues.sort((a, b) => a.localeCompare(b)), edges: g.edges });
|
|
9162
|
+
}
|
|
9163
|
+
return out.sort((a, b) => a.root.localeCompare(b.root));
|
|
9164
|
+
}
|
|
9165
|
+
function within(promise, ms) {
|
|
9166
|
+
let timer;
|
|
9167
|
+
const timeout = new Promise((_, reject) => {
|
|
9168
|
+
timer = setTimeout(() => reject(new Error("still sampling")), ms);
|
|
9169
|
+
});
|
|
9170
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
8635
9171
|
}
|
|
8636
|
-
function
|
|
8637
|
-
|
|
9172
|
+
function toDetectedMapEdge(connectionId, e) {
|
|
9173
|
+
const from = flowMapNodeId({ connectionId, queueName: e.from });
|
|
9174
|
+
const to = flowMapNodeId({ connectionId, queueName: e.to });
|
|
9175
|
+
return { id: detectedEdgeId(from, to), from, to, source: "detected", label: null, evidence: e.evidence };
|
|
8638
9176
|
}
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
}
|
|
8648
|
-
await Promise.all(workers);
|
|
8649
|
-
return results;
|
|
9177
|
+
function toManualMapEdge(row) {
|
|
9178
|
+
return {
|
|
9179
|
+
id: row.id,
|
|
9180
|
+
from: flowMapNodeId({ connectionId: row.fromConnectionId, queueName: row.fromQueue }),
|
|
9181
|
+
to: flowMapNodeId({ connectionId: row.toConnectionId, queueName: row.toQueue }),
|
|
9182
|
+
source: "manual",
|
|
9183
|
+
label: row.label ?? null,
|
|
9184
|
+
evidence: 0
|
|
9185
|
+
};
|
|
8650
9186
|
}
|
|
8651
|
-
|
|
8652
|
-
|
|
9187
|
+
function manualSummary(row) {
|
|
9188
|
+
return {
|
|
9189
|
+
id: row.id,
|
|
9190
|
+
kind: "manual",
|
|
9191
|
+
name: row.name,
|
|
9192
|
+
description: row.description ?? null,
|
|
9193
|
+
parentId: row.parentId ?? null,
|
|
9194
|
+
position: row.position,
|
|
9195
|
+
connectionId: null
|
|
9196
|
+
};
|
|
9197
|
+
}
|
|
9198
|
+
function sameRef(a, b) {
|
|
9199
|
+
return a.connectionId === b.connectionId && a.queueName === b.queueName;
|
|
9200
|
+
}
|
|
9201
|
+
var FlowMapsService = class {
|
|
9202
|
+
constructor(db, connections5, flows) {
|
|
8653
9203
|
this.db = db;
|
|
8654
9204
|
this.connections = connections5;
|
|
9205
|
+
this.flows = flows;
|
|
8655
9206
|
}
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
const
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
9207
|
+
// ---------------------------------------------------------------------------
|
|
9208
|
+
// reads
|
|
9209
|
+
// ---------------------------------------------------------------------------
|
|
9210
|
+
async list() {
|
|
9211
|
+
const rows = await this.db.select().from(flowMaps3);
|
|
9212
|
+
rows.sort(
|
|
9213
|
+
(a, b) => (a.parentId ?? "").localeCompare(b.parentId ?? "") || a.position - b.position || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)
|
|
9214
|
+
);
|
|
9215
|
+
const nodeCount = /* @__PURE__ */ new Map();
|
|
9216
|
+
const edgeCount = /* @__PURE__ */ new Map();
|
|
9217
|
+
if (rows.length > 0) {
|
|
9218
|
+
const ids = rows.map((r) => r.id);
|
|
9219
|
+
for (const n of await this.db.select({ mapId: flowMapNodes3.mapId }).from(flowMapNodes3).where(inArray2(flowMapNodes3.mapId, ids))) {
|
|
9220
|
+
nodeCount.set(n.mapId, (nodeCount.get(n.mapId) ?? 0) + 1);
|
|
9221
|
+
}
|
|
9222
|
+
for (const e of await this.db.select({ mapId: flowMapEdges3.mapId }).from(flowMapEdges3).where(inArray2(flowMapEdges3.mapId, ids))) {
|
|
9223
|
+
edgeCount.set(e.mapId, (edgeCount.get(e.mapId) ?? 0) + 1);
|
|
8668
9224
|
}
|
|
8669
9225
|
}
|
|
9226
|
+
const maps = rows.map((r) => ({ ...manualSummary(r), nodeCount: nodeCount.get(r.id) ?? 0, edgeCount: edgeCount.get(r.id) ?? 0 }));
|
|
9227
|
+
let detectedComplete = true;
|
|
9228
|
+
const perConnection = await Promise.all(
|
|
9229
|
+
(await this.connections.listRows()).map(async (row) => {
|
|
9230
|
+
try {
|
|
9231
|
+
return { row, components: detectedComponents(await within(this.flows.detectedEdges(row), DETECTED_WAIT_MS)) };
|
|
9232
|
+
} catch {
|
|
9233
|
+
detectedComplete = false;
|
|
9234
|
+
return { row, components: [] };
|
|
9235
|
+
}
|
|
9236
|
+
})
|
|
9237
|
+
);
|
|
9238
|
+
for (const { row, components } of perConnection) {
|
|
9239
|
+
components.forEach((c, position) => {
|
|
9240
|
+
maps.push({
|
|
9241
|
+
id: detectedMapId(row.id, c.root),
|
|
9242
|
+
kind: "detected",
|
|
9243
|
+
name: c.root,
|
|
9244
|
+
description: null,
|
|
9245
|
+
parentId: null,
|
|
9246
|
+
position,
|
|
9247
|
+
connectionId: row.id,
|
|
9248
|
+
nodeCount: c.queues.length,
|
|
9249
|
+
edgeCount: c.edges.length
|
|
9250
|
+
});
|
|
9251
|
+
});
|
|
9252
|
+
}
|
|
9253
|
+
return { maps, detectedComplete };
|
|
9254
|
+
}
|
|
9255
|
+
async get(id3) {
|
|
9256
|
+
return this.build(await this.source(id3));
|
|
9257
|
+
}
|
|
9258
|
+
/** What a map is made of (no Redis counts): the table rows, or the detected component. */
|
|
9259
|
+
async source(id3) {
|
|
9260
|
+
if (isDetectedMapId(id3)) return this.detectedSource(id3);
|
|
9261
|
+
const row = await this.getRow(id3);
|
|
9262
|
+
const nodes = await this.db.select().from(flowMapNodes3).where(eq7(flowMapNodes3.mapId, id3));
|
|
9263
|
+
const manualEdges = await this.db.select().from(flowMapEdges3).where(eq7(flowMapEdges3.mapId, id3));
|
|
9264
|
+
nodes.sort((a, b) => a.connectionId.localeCompare(b.connectionId) || a.queueName.localeCompare(b.queueName));
|
|
9265
|
+
manualEdges.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
|
|
8670
9266
|
return {
|
|
8671
|
-
|
|
8672
|
-
nodes,
|
|
8673
|
-
|
|
8674
|
-
sampledJobs: detected.sampledJobs
|
|
9267
|
+
summary: manualSummary(row),
|
|
9268
|
+
nodes: nodes.map((n) => ({ connectionId: n.connectionId, queueName: n.queueName, x: n.x ?? null, y: n.y ?? null })),
|
|
9269
|
+
manualEdges
|
|
8675
9270
|
};
|
|
8676
9271
|
}
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
9272
|
+
async detectedSource(id3) {
|
|
9273
|
+
const ref = parseNodeId(id3.slice(DETECTED_MAP_PREFIX.length));
|
|
9274
|
+
if (!ref) throw notFound("Flow map");
|
|
9275
|
+
const row = await this.connections.getRow(ref.connectionId);
|
|
9276
|
+
let edges;
|
|
9277
|
+
try {
|
|
9278
|
+
edges = await this.flows.detectedEdges(row);
|
|
9279
|
+
} catch {
|
|
9280
|
+
throw notFound("Flow map");
|
|
9281
|
+
}
|
|
9282
|
+
const components = detectedComponents(edges);
|
|
9283
|
+
const position = components.findIndex((c) => c.root === ref.queueName);
|
|
9284
|
+
const component = components[position];
|
|
9285
|
+
if (!component) throw notFound("Flow map");
|
|
9286
|
+
return {
|
|
9287
|
+
summary: { id: id3, kind: "detected", name: component.root, description: null, parentId: null, position, connectionId: row.id },
|
|
9288
|
+
nodes: component.queues.map((queueName) => ({ connectionId: row.id, queueName, x: null, y: null })),
|
|
9289
|
+
manualEdges: [],
|
|
9290
|
+
detectedEdges: component.edges
|
|
9291
|
+
};
|
|
8688
9292
|
}
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
9293
|
+
/**
|
|
9294
|
+
* Live counts + detected edges for a map's nodes: per connection, one
|
|
9295
|
+
* getQueueStats for only the queues on the map. Anything that cannot be read
|
|
9296
|
+
* (connection deleted or down, queue not discovered) is a `missing` node,
|
|
9297
|
+
* never an error: a map is a drawing first.
|
|
9298
|
+
*/
|
|
9299
|
+
async build(src) {
|
|
9300
|
+
const byConnection = /* @__PURE__ */ new Map();
|
|
9301
|
+
for (const n of src.nodes) {
|
|
9302
|
+
const list = byConnection.get(n.connectionId) ?? [];
|
|
9303
|
+
list.push(n);
|
|
9304
|
+
byConnection.set(n.connectionId, list);
|
|
9305
|
+
}
|
|
9306
|
+
const rows = new Map((await this.connections.listRows()).map((r) => [r.id, r]));
|
|
9307
|
+
const live = /* @__PURE__ */ new Map();
|
|
9308
|
+
const detectedEdges = [];
|
|
9309
|
+
await Promise.all(
|
|
9310
|
+
[...byConnection.entries()].map(async ([connectionId, nodes2]) => {
|
|
9311
|
+
const row = rows.get(connectionId);
|
|
9312
|
+
if (!row) return;
|
|
9313
|
+
await Promise.all([this.readCounts(row, nodes2, live), this.readDetected(row, src, detectedEdges)]);
|
|
9314
|
+
})
|
|
8703
9315
|
);
|
|
8704
|
-
const
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
to: e.parentQueue,
|
|
8718
|
-
source: "detected",
|
|
8719
|
-
evidence: e.count,
|
|
8720
|
-
label: null
|
|
8721
|
-
});
|
|
9316
|
+
const nodes = src.nodes.map((n) => {
|
|
9317
|
+
const id3 = flowMapNodeId(n);
|
|
9318
|
+
return {
|
|
9319
|
+
id: id3,
|
|
9320
|
+
connectionId: n.connectionId,
|
|
9321
|
+
queueName: n.queueName,
|
|
9322
|
+
x: n.x,
|
|
9323
|
+
y: n.y,
|
|
9324
|
+
...live.get(id3) ?? {
|
|
9325
|
+
connectionName: rows.get(n.connectionId)?.name ?? n.connectionId,
|
|
9326
|
+
counts: { ...EMPTY_COUNTS },
|
|
9327
|
+
isPaused: false,
|
|
9328
|
+
missing: true
|
|
8722
9329
|
}
|
|
9330
|
+
};
|
|
9331
|
+
});
|
|
9332
|
+
const onMap = new Set(nodes.map((n) => n.id));
|
|
9333
|
+
const manual = src.manualEdges.map(toManualMapEdge);
|
|
9334
|
+
const drawn = new Set(manual.map((e) => `${e.from}->${e.to}`));
|
|
9335
|
+
const edges = [
|
|
9336
|
+
...manual,
|
|
9337
|
+
...detectedEdges.filter((e) => onMap.has(e.from) && onMap.has(e.to) && !drawn.has(`${e.from}->${e.to}`))
|
|
9338
|
+
];
|
|
9339
|
+
return { ...src.summary, nodeCount: nodes.length, edgeCount: edges.length, nodes, edges };
|
|
9340
|
+
}
|
|
9341
|
+
async readCounts(row, nodes, live) {
|
|
9342
|
+
try {
|
|
9343
|
+
const inspector = this.connections.inspectorFor(row);
|
|
9344
|
+
const discovered = new Set(await withRedis(() => inspector.discoverQueues()));
|
|
9345
|
+
const present = [...new Set(nodes.map((n) => n.queueName))].filter((q) => discovered.has(q));
|
|
9346
|
+
if (present.length === 0) return;
|
|
9347
|
+
const stats = await withRedis(() => inspector.getQueueStats(present));
|
|
9348
|
+
for (const q of present) {
|
|
9349
|
+
const s = stats[q];
|
|
9350
|
+
if (!s) continue;
|
|
9351
|
+
live.set(flowMapNodeId({ connectionId: row.id, queueName: q }), {
|
|
9352
|
+
connectionName: row.name,
|
|
9353
|
+
counts: s.counts,
|
|
9354
|
+
isPaused: s.isPaused,
|
|
9355
|
+
missing: false
|
|
9356
|
+
});
|
|
8723
9357
|
}
|
|
9358
|
+
} catch {
|
|
8724
9359
|
}
|
|
8725
|
-
return { nodes, edges: [...edgeMap.values()], sampledJobs };
|
|
8726
9360
|
}
|
|
8727
|
-
async
|
|
8728
|
-
|
|
8729
|
-
|
|
9361
|
+
async readDetected(row, src, out) {
|
|
9362
|
+
if (src.detectedEdges) {
|
|
9363
|
+
if (src.summary.connectionId === row.id) out.push(...src.detectedEdges.map((e) => toDetectedMapEdge(row.id, e)));
|
|
9364
|
+
return;
|
|
9365
|
+
}
|
|
9366
|
+
try {
|
|
9367
|
+
const edges = await within(this.flows.detectedEdges(row), DETECTED_WAIT_MS);
|
|
9368
|
+
out.push(...edges.map((e) => toDetectedMapEdge(row.id, e)));
|
|
9369
|
+
} catch {
|
|
9370
|
+
}
|
|
8730
9371
|
}
|
|
8731
|
-
|
|
9372
|
+
// ---------------------------------------------------------------------------
|
|
9373
|
+
// writes (manual maps only, except copy)
|
|
9374
|
+
// ---------------------------------------------------------------------------
|
|
9375
|
+
async getRow(id3) {
|
|
9376
|
+
const rows = await this.db.select().from(flowMaps3).where(eq7(flowMaps3.id, id3)).limit(1);
|
|
9377
|
+
const row = rows[0];
|
|
9378
|
+
if (!row) throw notFound("Flow map");
|
|
9379
|
+
return row;
|
|
9380
|
+
}
|
|
9381
|
+
/** The row of a map a write may touch: 409 for a detected id, 404 for an unknown one. */
|
|
9382
|
+
async writable(id3) {
|
|
9383
|
+
if (isDetectedMapId(id3)) throw detectedMapReadOnly();
|
|
9384
|
+
return this.getRow(id3);
|
|
9385
|
+
}
|
|
9386
|
+
async touch(id3) {
|
|
9387
|
+
await this.db.update(flowMaps3).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq7(flowMaps3.id, id3));
|
|
9388
|
+
}
|
|
9389
|
+
/** New maps go to the bottom of their level. */
|
|
9390
|
+
async nextPosition(parentId) {
|
|
9391
|
+
const siblings = await this.db.select({ id: flowMaps3.id }).from(flowMaps3).where(parentId ? eq7(flowMaps3.parentId, parentId) : isNull2(flowMaps3.parentId));
|
|
9392
|
+
return siblings.length;
|
|
9393
|
+
}
|
|
9394
|
+
async insertMap(input) {
|
|
9395
|
+
const parentId = input.parentId ?? null;
|
|
9396
|
+
if (parentId) await this.getRow(parentId);
|
|
9397
|
+
const id3 = nanoid6();
|
|
9398
|
+
const now = /* @__PURE__ */ new Date();
|
|
9399
|
+
await this.db.insert(flowMaps3).values({
|
|
9400
|
+
id: id3,
|
|
9401
|
+
name: input.name,
|
|
9402
|
+
description: input.description ?? null,
|
|
9403
|
+
parentId,
|
|
9404
|
+
position: await this.nextPosition(parentId),
|
|
9405
|
+
createdAt: now,
|
|
9406
|
+
updatedAt: now
|
|
9407
|
+
});
|
|
9408
|
+
return id3;
|
|
9409
|
+
}
|
|
9410
|
+
async create(input) {
|
|
9411
|
+
return this.get(await this.insertMap(input));
|
|
9412
|
+
}
|
|
9413
|
+
async update(id3, input) {
|
|
9414
|
+
await this.writable(id3);
|
|
9415
|
+
if (input.parentId) await this.assertNoCycle(id3, input.parentId);
|
|
9416
|
+
const patch = { updatedAt: /* @__PURE__ */ new Date() };
|
|
9417
|
+
if (input.name !== void 0) patch.name = input.name;
|
|
9418
|
+
if (input.description !== void 0) patch.description = input.description;
|
|
9419
|
+
if (input.parentId !== void 0) patch.parentId = input.parentId;
|
|
9420
|
+
if (input.position !== void 0) patch.position = input.position;
|
|
9421
|
+
await this.db.update(flowMaps3).set(patch).where(eq7(flowMaps3.id, id3));
|
|
9422
|
+
return this.get(id3);
|
|
9423
|
+
}
|
|
9424
|
+
/** Moving `id` under `parentId` must not put it under itself or one of its descendants. */
|
|
9425
|
+
async assertNoCycle(id3, parentId) {
|
|
9426
|
+
if (isDetectedMapId(parentId)) throw notFound("Flow map");
|
|
9427
|
+
const all = await this.db.select({ id: flowMaps3.id, parentId: flowMaps3.parentId }).from(flowMaps3);
|
|
9428
|
+
const parentOf = new Map(all.map((r) => [r.id, r.parentId ?? null]));
|
|
9429
|
+
if (!parentOf.has(parentId)) throw notFound("Flow map");
|
|
9430
|
+
let cursor = parentId;
|
|
9431
|
+
for (let steps = 0; cursor !== null && steps <= all.length; steps++) {
|
|
9432
|
+
if (cursor === id3) throw conflict("A flow map cannot be moved under itself or one of its own maps");
|
|
9433
|
+
cursor = parentOf.get(cursor) ?? null;
|
|
9434
|
+
}
|
|
9435
|
+
}
|
|
9436
|
+
async remove(id3) {
|
|
9437
|
+
const row = await this.writable(id3);
|
|
9438
|
+
await this.db.transaction(async (tx) => {
|
|
9439
|
+
await tx.update(flowMaps3).set({ parentId: row.parentId ?? null, updatedAt: /* @__PURE__ */ new Date() }).where(eq7(flowMaps3.parentId, id3));
|
|
9440
|
+
await tx.delete(flowMapEdges3).where(eq7(flowMapEdges3.mapId, id3));
|
|
9441
|
+
await tx.delete(flowMapNodes3).where(eq7(flowMapNodes3.mapId, id3));
|
|
9442
|
+
await tx.delete(flowMaps3).where(eq7(flowMaps3.id, id3));
|
|
9443
|
+
});
|
|
9444
|
+
}
|
|
9445
|
+
async findNode(mapId, ref) {
|
|
9446
|
+
const rows = await this.db.select().from(flowMapNodes3).where(and6(eq7(flowMapNodes3.mapId, mapId), eq7(flowMapNodes3.connectionId, ref.connectionId), eq7(flowMapNodes3.queueName, ref.queueName))).limit(1);
|
|
9447
|
+
return rows[0] ?? null;
|
|
9448
|
+
}
|
|
9449
|
+
/** Puts a queue on the map if it is not there yet. Returns true when it was added. */
|
|
9450
|
+
async ensureNode(mapId, ref, pos = {}) {
|
|
9451
|
+
if (await this.findNode(mapId, ref)) return false;
|
|
9452
|
+
await this.db.insert(flowMapNodes3).values({
|
|
9453
|
+
mapId,
|
|
9454
|
+
connectionId: ref.connectionId,
|
|
9455
|
+
queueName: ref.queueName,
|
|
9456
|
+
x: pos.x ?? null,
|
|
9457
|
+
y: pos.y ?? null
|
|
9458
|
+
});
|
|
9459
|
+
return true;
|
|
9460
|
+
}
|
|
9461
|
+
async addNode(id3, input) {
|
|
9462
|
+
await this.writable(id3);
|
|
8732
9463
|
await this.connections.getRow(input.connectionId);
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
9464
|
+
const added = await this.ensureNode(id3, input, input);
|
|
9465
|
+
if (!added && (input.x !== void 0 || input.y !== void 0)) {
|
|
9466
|
+
const patch = {};
|
|
9467
|
+
if (input.x !== void 0) patch.x = input.x;
|
|
9468
|
+
if (input.y !== void 0) patch.y = input.y;
|
|
9469
|
+
await this.db.update(flowMapNodes3).set(patch).where(and6(eq7(flowMapNodes3.mapId, id3), eq7(flowMapNodes3.connectionId, input.connectionId), eq7(flowMapNodes3.queueName, input.queueName)));
|
|
9470
|
+
}
|
|
9471
|
+
await this.touch(id3);
|
|
9472
|
+
return this.get(id3);
|
|
9473
|
+
}
|
|
9474
|
+
async removeNode(id3, nodeId) {
|
|
9475
|
+
await this.writable(id3);
|
|
9476
|
+
const ref = parseNodeId(nodeId);
|
|
9477
|
+
if (!ref || !await this.findNode(id3, ref)) throw notFound("Queue on this flow map");
|
|
9478
|
+
await this.db.transaction(async (tx) => {
|
|
9479
|
+
await tx.delete(flowMapEdges3).where(
|
|
9480
|
+
and6(
|
|
9481
|
+
eq7(flowMapEdges3.mapId, id3),
|
|
9482
|
+
or3(
|
|
9483
|
+
and6(eq7(flowMapEdges3.fromConnectionId, ref.connectionId), eq7(flowMapEdges3.fromQueue, ref.queueName)),
|
|
9484
|
+
and6(eq7(flowMapEdges3.toConnectionId, ref.connectionId), eq7(flowMapEdges3.toQueue, ref.queueName))
|
|
9485
|
+
)
|
|
9486
|
+
)
|
|
9487
|
+
);
|
|
9488
|
+
await tx.delete(flowMapNodes3).where(and6(eq7(flowMapNodes3.mapId, id3), eq7(flowMapNodes3.connectionId, ref.connectionId), eq7(flowMapNodes3.queueName, ref.queueName)));
|
|
9489
|
+
});
|
|
9490
|
+
await this.touch(id3);
|
|
9491
|
+
return this.get(id3);
|
|
9492
|
+
}
|
|
9493
|
+
async saveLayout(id3, input) {
|
|
9494
|
+
await this.writable(id3);
|
|
9495
|
+
const known = new Set(
|
|
9496
|
+
(await this.db.select({ connectionId: flowMapNodes3.connectionId, queueName: flowMapNodes3.queueName }).from(flowMapNodes3).where(eq7(flowMapNodes3.mapId, id3))).map(
|
|
9497
|
+
(n) => flowMapNodeId(n)
|
|
8739
9498
|
)
|
|
8740
|
-
)
|
|
8741
|
-
|
|
8742
|
-
const
|
|
8743
|
-
await this.db.
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
9499
|
+
);
|
|
9500
|
+
const positions = /* @__PURE__ */ new Map();
|
|
9501
|
+
for (const p of input.positions) if (known.has(p.nodeId)) positions.set(p.nodeId, { x: p.x, y: p.y });
|
|
9502
|
+
await this.db.transaction(async (tx) => {
|
|
9503
|
+
for (const [nodeId, pos] of positions) {
|
|
9504
|
+
const ref = parseNodeId(nodeId);
|
|
9505
|
+
await tx.update(flowMapNodes3).set(pos).where(and6(eq7(flowMapNodes3.mapId, id3), eq7(flowMapNodes3.connectionId, ref.connectionId), eq7(flowMapNodes3.queueName, ref.queueName)));
|
|
9506
|
+
}
|
|
9507
|
+
await tx.update(flowMaps3).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq7(flowMaps3.id, id3));
|
|
8750
9508
|
});
|
|
8751
|
-
|
|
9509
|
+
}
|
|
9510
|
+
async findEdge(mapId, from, to) {
|
|
9511
|
+
const rows = await this.db.select().from(flowMapEdges3).where(
|
|
9512
|
+
and6(
|
|
9513
|
+
eq7(flowMapEdges3.mapId, mapId),
|
|
9514
|
+
eq7(flowMapEdges3.fromConnectionId, from.connectionId),
|
|
9515
|
+
eq7(flowMapEdges3.fromQueue, from.queueName),
|
|
9516
|
+
eq7(flowMapEdges3.toConnectionId, to.connectionId),
|
|
9517
|
+
eq7(flowMapEdges3.toQueue, to.queueName)
|
|
9518
|
+
)
|
|
9519
|
+
).limit(1);
|
|
9520
|
+
return rows[0] ?? null;
|
|
9521
|
+
}
|
|
9522
|
+
/**
|
|
9523
|
+
* Idempotent on the full (from, to) refs, connection included: the same queue
|
|
9524
|
+
* name on two connections is two different nodes, and an edge may go from one
|
|
9525
|
+
* connection to another (Redis → Postgres included).
|
|
9526
|
+
*/
|
|
9527
|
+
async addEdge(id3, input) {
|
|
9528
|
+
await this.writable(id3);
|
|
9529
|
+
if (sameRef(input.from, input.to)) throw conflict("A queue cannot flow into itself");
|
|
9530
|
+
await this.connections.getRow(input.from.connectionId);
|
|
9531
|
+
if (input.to.connectionId !== input.from.connectionId) await this.connections.getRow(input.to.connectionId);
|
|
9532
|
+
await this.ensureNode(id3, input.from);
|
|
9533
|
+
await this.ensureNode(id3, input.to);
|
|
9534
|
+
const existing = await this.findEdge(id3, input.from, input.to);
|
|
9535
|
+
if (existing) {
|
|
9536
|
+
if (input.label !== void 0) await this.db.update(flowMapEdges3).set({ label: input.label }).where(eq7(flowMapEdges3.id, existing.id));
|
|
9537
|
+
} else {
|
|
9538
|
+
await this.db.insert(flowMapEdges3).values({
|
|
9539
|
+
id: nanoid6(),
|
|
9540
|
+
mapId: id3,
|
|
9541
|
+
fromConnectionId: input.from.connectionId,
|
|
9542
|
+
fromQueue: input.from.queueName,
|
|
9543
|
+
toConnectionId: input.to.connectionId,
|
|
9544
|
+
toQueue: input.to.queueName,
|
|
9545
|
+
label: input.label ?? null,
|
|
9546
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
9547
|
+
});
|
|
9548
|
+
}
|
|
9549
|
+
await this.touch(id3);
|
|
9550
|
+
return this.get(id3);
|
|
9551
|
+
}
|
|
9552
|
+
async edgeOf(mapId, edgeId) {
|
|
9553
|
+
const rows = await this.db.select().from(flowMapEdges3).where(and6(eq7(flowMapEdges3.mapId, mapId), eq7(flowMapEdges3.id, edgeId))).limit(1);
|
|
8752
9554
|
const row = rows[0];
|
|
8753
|
-
if (!row) throw notFound("Flow edge");
|
|
8754
|
-
return
|
|
9555
|
+
if (!row) throw notFound("Flow map edge");
|
|
9556
|
+
return row;
|
|
8755
9557
|
}
|
|
8756
|
-
async
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
await this.db.
|
|
9558
|
+
async updateEdge(id3, edgeId, input) {
|
|
9559
|
+
await this.writable(id3);
|
|
9560
|
+
await this.edgeOf(id3, edgeId);
|
|
9561
|
+
await this.db.update(flowMapEdges3).set({ label: input.label }).where(eq7(flowMapEdges3.id, edgeId));
|
|
9562
|
+
await this.touch(id3);
|
|
9563
|
+
return this.get(id3);
|
|
9564
|
+
}
|
|
9565
|
+
async removeEdge(id3, edgeId) {
|
|
9566
|
+
await this.writable(id3);
|
|
9567
|
+
await this.edgeOf(id3, edgeId);
|
|
9568
|
+
await this.db.delete(flowMapEdges3).where(eq7(flowMapEdges3.id, edgeId));
|
|
9569
|
+
await this.touch(id3);
|
|
9570
|
+
return this.get(id3);
|
|
9571
|
+
}
|
|
9572
|
+
/**
|
|
9573
|
+
* A new manual map with the same queues and positions. Works on detected maps
|
|
9574
|
+
* too (that is how one becomes editable). A manual map's drawn edges come
|
|
9575
|
+
* along; detected edges are not turned into manual ones, they keep being drawn
|
|
9576
|
+
* from the live sample.
|
|
9577
|
+
*/
|
|
9578
|
+
async copy(id3, input) {
|
|
9579
|
+
const src = await this.source(id3);
|
|
9580
|
+
const name = input.name ?? `${src.summary.name} (copy)`.slice(0, NAME_MAX);
|
|
9581
|
+
const newId = await this.insertMap({ name, description: src.summary.description, parentId: input.parentId ?? null });
|
|
9582
|
+
await this.db.transaction(async (tx) => {
|
|
9583
|
+
if (src.nodes.length > 0) {
|
|
9584
|
+
await tx.insert(flowMapNodes3).values(src.nodes.map((n) => ({ mapId: newId, connectionId: n.connectionId, queueName: n.queueName, x: n.x, y: n.y })));
|
|
9585
|
+
}
|
|
9586
|
+
if (src.manualEdges.length > 0) {
|
|
9587
|
+
const now = /* @__PURE__ */ new Date();
|
|
9588
|
+
await tx.insert(flowMapEdges3).values(
|
|
9589
|
+
src.manualEdges.map((e) => ({
|
|
9590
|
+
id: nanoid6(),
|
|
9591
|
+
mapId: newId,
|
|
9592
|
+
fromConnectionId: e.fromConnectionId,
|
|
9593
|
+
fromQueue: e.fromQueue,
|
|
9594
|
+
toConnectionId: e.toConnectionId,
|
|
9595
|
+
toQueue: e.toQueue,
|
|
9596
|
+
label: e.label ?? null,
|
|
9597
|
+
createdAt: now
|
|
9598
|
+
}))
|
|
9599
|
+
);
|
|
9600
|
+
}
|
|
9601
|
+
});
|
|
9602
|
+
return this.get(newId);
|
|
8760
9603
|
}
|
|
8761
9604
|
};
|
|
8762
9605
|
|
|
8763
9606
|
// apps/server/src/ee/services/folders.ts
|
|
8764
|
-
import { eq as
|
|
8765
|
-
import { nanoid as
|
|
9607
|
+
import { eq as eq8, inArray as inArray3 } from "drizzle-orm";
|
|
9608
|
+
import { nanoid as nanoid7 } from "nanoid";
|
|
8766
9609
|
var FoldersService = class {
|
|
8767
9610
|
constructor(db) {
|
|
8768
9611
|
this.db = db;
|
|
@@ -8774,7 +9617,7 @@ var FoldersService = class {
|
|
|
8774
9617
|
const rows = await this.db.select().from(folders3).orderBy(folders3.position, folders3.name);
|
|
8775
9618
|
if (rows.length === 0) return [];
|
|
8776
9619
|
const refs = await this.db.select().from(folderQueues3).where(
|
|
8777
|
-
|
|
9620
|
+
inArray3(
|
|
8778
9621
|
folderQueues3.folderId,
|
|
8779
9622
|
rows.map((r) => r.id)
|
|
8780
9623
|
)
|
|
@@ -8788,10 +9631,10 @@ var FoldersService = class {
|
|
|
8788
9631
|
return rows.map((row) => toFolderDto(row, byFolder.get(row.id) ?? []));
|
|
8789
9632
|
}
|
|
8790
9633
|
async get(id3) {
|
|
8791
|
-
const rows = await this.db.select().from(folders3).where(
|
|
9634
|
+
const rows = await this.db.select().from(folders3).where(eq8(folders3.id, id3)).limit(1);
|
|
8792
9635
|
const row = rows[0];
|
|
8793
9636
|
if (!row) throw notFound("Folder");
|
|
8794
|
-
const refs = await this.db.select().from(folderQueues3).where(
|
|
9637
|
+
const refs = await this.db.select().from(folderQueues3).where(eq8(folderQueues3.folderId, id3));
|
|
8795
9638
|
return toFolderDto(
|
|
8796
9639
|
row,
|
|
8797
9640
|
refs.map((r) => ({ connectionId: r.connectionId, queueName: r.queueName }))
|
|
@@ -8799,7 +9642,7 @@ var FoldersService = class {
|
|
|
8799
9642
|
}
|
|
8800
9643
|
async create(input) {
|
|
8801
9644
|
if (input.parentId) await this.get(input.parentId);
|
|
8802
|
-
const id3 =
|
|
9645
|
+
const id3 = nanoid7();
|
|
8803
9646
|
const siblings = await this.db.select({ id: folders3.id }).from(folders3);
|
|
8804
9647
|
await this.db.insert(folders3).values({
|
|
8805
9648
|
id: id3,
|
|
@@ -8822,21 +9665,21 @@ var FoldersService = class {
|
|
|
8822
9665
|
if (input.parentId !== void 0) patch.parentId = input.parentId;
|
|
8823
9666
|
if (input.position !== void 0) patch.position = input.position;
|
|
8824
9667
|
if (Object.keys(patch).length > 0) {
|
|
8825
|
-
await this.db.update(folders3).set(patch).where(
|
|
9668
|
+
await this.db.update(folders3).set(patch).where(eq8(folders3.id, id3));
|
|
8826
9669
|
}
|
|
8827
9670
|
return this.get(id3);
|
|
8828
9671
|
}
|
|
8829
9672
|
async remove(id3) {
|
|
8830
9673
|
await this.get(id3);
|
|
8831
|
-
await this.db.update(folders3).set({ parentId: null }).where(
|
|
8832
|
-
await this.db.delete(folderQueues3).where(
|
|
8833
|
-
await this.db.delete(folders3).where(
|
|
9674
|
+
await this.db.update(folders3).set({ parentId: null }).where(eq8(folders3.parentId, id3));
|
|
9675
|
+
await this.db.delete(folderQueues3).where(eq8(folderQueues3.folderId, id3));
|
|
9676
|
+
await this.db.delete(folders3).where(eq8(folders3.id, id3));
|
|
8834
9677
|
}
|
|
8835
9678
|
async setQueues(id3, queues) {
|
|
8836
9679
|
await this.get(id3);
|
|
8837
9680
|
const unique = /* @__PURE__ */ new Map();
|
|
8838
9681
|
for (const q of queues) unique.set(`${q.connectionId}|${q.queueName}`, q);
|
|
8839
|
-
await this.db.delete(folderQueues3).where(
|
|
9682
|
+
await this.db.delete(folderQueues3).where(eq8(folderQueues3.folderId, id3));
|
|
8840
9683
|
if (unique.size > 0) {
|
|
8841
9684
|
await this.db.insert(folderQueues3).values([...unique.values()].map((q) => ({ folderId: id3, connectionId: q.connectionId, queueName: q.queueName })));
|
|
8842
9685
|
}
|
|
@@ -9050,8 +9893,8 @@ function buildPostgresWarnings(info, error) {
|
|
|
9050
9893
|
}
|
|
9051
9894
|
|
|
9052
9895
|
// apps/server/src/ee/services/sso.ts
|
|
9053
|
-
import { asc as asc2, eq as
|
|
9054
|
-
import { nanoid as
|
|
9896
|
+
import { asc as asc2, eq as eq9 } from "drizzle-orm";
|
|
9897
|
+
import { nanoid as nanoid8 } from "nanoid";
|
|
9055
9898
|
var REQUIRE_SSO_KEY = "sso.require_sso";
|
|
9056
9899
|
var AUTO_PROVISION_KEY = "sso.auto_provision";
|
|
9057
9900
|
var AUTO_PROVISION_DOMAINS_KEY = "sso.auto_provision_domains";
|
|
@@ -9093,7 +9936,7 @@ var SsoService = class {
|
|
|
9093
9936
|
return rows.map((r) => this.toDto(r));
|
|
9094
9937
|
}
|
|
9095
9938
|
async getRow(id3) {
|
|
9096
|
-
const rows = await this.db.select().from(ssoProviders3).where(
|
|
9939
|
+
const rows = await this.db.select().from(ssoProviders3).where(eq9(ssoProviders3.id, id3)).limit(1);
|
|
9097
9940
|
const row = rows[0];
|
|
9098
9941
|
if (!row) throw notFound("SSO provider");
|
|
9099
9942
|
return row;
|
|
@@ -9102,7 +9945,7 @@ var SsoService = class {
|
|
|
9102
9945
|
return this.toDto(await this.getRow(id3));
|
|
9103
9946
|
}
|
|
9104
9947
|
async create(input) {
|
|
9105
|
-
const id3 =
|
|
9948
|
+
const id3 = nanoid8();
|
|
9106
9949
|
const now = /* @__PURE__ */ new Date();
|
|
9107
9950
|
const { config, secretEnc } = this.splitSecret(input.kind, input.config);
|
|
9108
9951
|
await this.db.insert(ssoProviders3).values({
|
|
@@ -9131,19 +9974,19 @@ var SsoService = class {
|
|
|
9131
9974
|
}
|
|
9132
9975
|
if (Object.keys(patch).length > 0) {
|
|
9133
9976
|
patch.updatedAt = /* @__PURE__ */ new Date();
|
|
9134
|
-
await this.db.update(ssoProviders3).set(patch).where(
|
|
9977
|
+
await this.db.update(ssoProviders3).set(patch).where(eq9(ssoProviders3.id, id3));
|
|
9135
9978
|
}
|
|
9136
9979
|
return this.get(id3);
|
|
9137
9980
|
}
|
|
9138
9981
|
async remove(id3) {
|
|
9139
9982
|
const row = await this.getRow(id3);
|
|
9140
9983
|
if (row.enabled && await this.requireSso()) {
|
|
9141
|
-
const others = (await this.db.select().from(ssoProviders3).where(
|
|
9984
|
+
const others = (await this.db.select().from(ssoProviders3).where(eq9(ssoProviders3.enabled, true))).filter((r) => r.id !== id3);
|
|
9142
9985
|
if (others.length === 0) {
|
|
9143
9986
|
throw conflict('This is the only enabled SSO provider and "require SSO" is on. Turn that off before deleting it.');
|
|
9144
9987
|
}
|
|
9145
9988
|
}
|
|
9146
|
-
await this.db.delete(ssoProviders3).where(
|
|
9989
|
+
await this.db.delete(ssoProviders3).where(eq9(ssoProviders3.id, id3));
|
|
9147
9990
|
}
|
|
9148
9991
|
/**
|
|
9149
9992
|
* Pulls the write-only secret out of a config object, encrypts it, and
|
|
@@ -9178,7 +10021,7 @@ var SsoService = class {
|
|
|
9178
10021
|
}
|
|
9179
10022
|
async setRequireSso(value) {
|
|
9180
10023
|
if (value) {
|
|
9181
|
-
const enabled = await this.db.select().from(ssoProviders3).where(
|
|
10024
|
+
const enabled = await this.db.select().from(ssoProviders3).where(eq9(ssoProviders3.enabled, true));
|
|
9182
10025
|
if (enabled.length === 0) {
|
|
9183
10026
|
throw conflict("Add and enable an SSO provider before requiring SSO, or nobody will be able to sign in.");
|
|
9184
10027
|
}
|
|
@@ -9241,7 +10084,7 @@ var SsoService = class {
|
|
|
9241
10084
|
* IdP: the button label is enough to start the flow.
|
|
9242
10085
|
*/
|
|
9243
10086
|
async loginOptions() {
|
|
9244
|
-
const rows = await this.db.select().from(ssoProviders3).where(
|
|
10087
|
+
const rows = await this.db.select().from(ssoProviders3).where(eq9(ssoProviders3.enabled, true)).orderBy(asc2(ssoProviders3.createdAt));
|
|
9245
10088
|
const providers = rows.map((r) => ({ id: r.id, kind: r.kind, name: r.name }));
|
|
9246
10089
|
const requireSso = providers.length > 0 && await this.requireSso();
|
|
9247
10090
|
return {
|
|
@@ -9259,7 +10102,7 @@ var SsoService = class {
|
|
|
9259
10102
|
async resolveUser(email) {
|
|
9260
10103
|
const normalised = email.trim().toLowerCase();
|
|
9261
10104
|
if (!normalised) return null;
|
|
9262
|
-
const rows = await this.db.select().from(users3).where(
|
|
10105
|
+
const rows = await this.db.select().from(users3).where(eq9(users3.email, normalised)).limit(1);
|
|
9263
10106
|
const row = rows[0];
|
|
9264
10107
|
return row ? toUserDto(row) : null;
|
|
9265
10108
|
}
|
|
@@ -9288,6 +10131,9 @@ function textResult(value, isError = false) {
|
|
|
9288
10131
|
}
|
|
9289
10132
|
async function forward(ctx, req) {
|
|
9290
10133
|
const { status, body } = await ctx.call(req);
|
|
10134
|
+
return apiResult(status, body);
|
|
10135
|
+
}
|
|
10136
|
+
function apiResult(status, body) {
|
|
9291
10137
|
if (status >= 400) {
|
|
9292
10138
|
const b = body ?? {};
|
|
9293
10139
|
const hint = status === 403 ? " (MCP calls act with your role, capped to the access this connection was given: read = viewer, write = operator)" : "";
|
|
@@ -9310,6 +10156,23 @@ var zConn = z10.object({ connection_id: z10.string().min(1) });
|
|
|
9310
10156
|
var zQueue = zConn.extend({ queue: z10.string().min(1) });
|
|
9311
10157
|
var zJob = zQueue.extend({ job_id: z10.string().min(1) });
|
|
9312
10158
|
var zState = z10.enum(JOB_STATES);
|
|
10159
|
+
var zMap = z10.object({ map_id: z10.string().min(1) });
|
|
10160
|
+
var mapPath = (a) => `/api/flow-maps/${seg(a.map_id)}`;
|
|
10161
|
+
async function connectionOrDefault(ctx, given) {
|
|
10162
|
+
if (given) return { id: given };
|
|
10163
|
+
const res = await ctx.call({ method: "GET", url: "/api/connections" });
|
|
10164
|
+
if (res.status >= 400) return { error: apiResult(res.status, res.body) };
|
|
10165
|
+
const list = Array.isArray(res.body) ? res.body : [];
|
|
10166
|
+
if (list.length === 1) return { id: list[0].id };
|
|
10167
|
+
if (list.length === 0) return { error: textResult("validation: Bullpane has no connection yet; add one in the dashboard first.", true) };
|
|
10168
|
+
const names = list.map((c) => `${c.name} (${c.id})`).join(", ");
|
|
10169
|
+
return {
|
|
10170
|
+
error: textResult(
|
|
10171
|
+
`validation: connection_id is required: this installation has ${list.length} connections (${names}). Pass the connection of each queue; list_connections shows them.`,
|
|
10172
|
+
true
|
|
10173
|
+
)
|
|
10174
|
+
};
|
|
10175
|
+
}
|
|
9313
10176
|
var ro = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
9314
10177
|
var rw = (destructive, idempotent) => ({ readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false });
|
|
9315
10178
|
function tool(def) {
|
|
@@ -9551,6 +10414,19 @@ var MCP_TOOLS = [
|
|
|
9551
10414
|
schema: zQueue,
|
|
9552
10415
|
run: (a, ctx) => forward(ctx, { method: "POST", url: `${queuePath(a)}/resume` })
|
|
9553
10416
|
}),
|
|
10417
|
+
tool({
|
|
10418
|
+
name: "promote_matching",
|
|
10419
|
+
title: "Promote every matching delayed job",
|
|
10420
|
+
description: "Promotes the delayed jobs of a BullMQ Pro group (group_id) and / or whose id, name or data contains query, beyond the 500-id limit of bulk_job_action. Each call promotes at most 2000 and returns next_cursor: call again with it until it is null. Grouped jobs go back into their group (needs BullMQ Pro's package next to Bullpane).",
|
|
10421
|
+
access: "write",
|
|
10422
|
+
inputSchema: object(
|
|
10423
|
+
{ ...QUEUE, query: str4("Text the job id, name or data must contain"), group_id: str4("BullMQ Pro group id (exact)"), cursor: str4("next_cursor of the previous call") },
|
|
10424
|
+
["connection_id", "queue"]
|
|
10425
|
+
),
|
|
10426
|
+
annotations: rw(false, false),
|
|
10427
|
+
schema: zQueue.extend({ query: z10.string().max(500).optional(), group_id: z10.string().min(1).max(200).optional(), cursor: z10.string().max(20).optional() }).refine((a) => !!a.query?.trim() || !!a.group_id, { message: "query or group_id is required", path: ["query"] }),
|
|
10428
|
+
run: (a, ctx) => forward(ctx, { method: "POST", url: `${queuePath(a)}/jobs/promote-matching`, body: { query: a.query, groupId: a.group_id, cursor: a.cursor } })
|
|
10429
|
+
}),
|
|
9554
10430
|
tool({
|
|
9555
10431
|
name: "pause_group",
|
|
9556
10432
|
title: "Pause a group (BullMQ Pro)",
|
|
@@ -9571,6 +10447,158 @@ var MCP_TOOLS = [
|
|
|
9571
10447
|
schema: zQueue.extend({ group_id: z10.string().min(1).max(200) }),
|
|
9572
10448
|
run: (a, ctx) => forward(ctx, { method: "POST", url: `${queuePath(a)}/groups/${seg(a.group_id)}/resume` })
|
|
9573
10449
|
}),
|
|
10450
|
+
// ----- flow maps (Pro): drawings of a process, read and write -----
|
|
10451
|
+
tool({
|
|
10452
|
+
name: "list_flow_maps",
|
|
10453
|
+
title: "List flow maps",
|
|
10454
|
+
description: "Lists the flow maps: named diagrams of the queues one process goes through. kind manual = drawn by people (nested by parentId, like folders); kind detected = computed, read-only, one per group of queues linked by BullMQ FlowProducer parents. Use get_flow_map for a map's queues, arrows and live counts.",
|
|
10455
|
+
access: "read",
|
|
10456
|
+
inputSchema: object({}, []),
|
|
10457
|
+
annotations: ro,
|
|
10458
|
+
schema: z10.object({}),
|
|
10459
|
+
run: (_a, ctx) => forward(ctx, { method: "GET", url: "/api/flow-maps" })
|
|
10460
|
+
}),
|
|
10461
|
+
tool({
|
|
10462
|
+
name: "get_flow_map",
|
|
10463
|
+
title: "Get flow map",
|
|
10464
|
+
description: "One flow map: its queues (node id = connectionId:queueName) with live job counts, and its arrows (edges, from \u2192 to). source manual = drawn, with an edge id for remove_flow_edge; source detected = seen in BullMQ flows, not removable. missing: true means the queue was not found on its connection.",
|
|
10465
|
+
access: "read",
|
|
10466
|
+
inputSchema: object({ map_id: str4("Flow map id, from list_flow_maps") }, ["map_id"]),
|
|
10467
|
+
annotations: ro,
|
|
10468
|
+
schema: zMap,
|
|
10469
|
+
run: (a, ctx) => forward(ctx, { method: "GET", url: mapPath(a) })
|
|
10470
|
+
}),
|
|
10471
|
+
tool({
|
|
10472
|
+
name: "create_flow_map",
|
|
10473
|
+
title: "Create flow map",
|
|
10474
|
+
description: "Creates an empty flow map to draw a process on. Then call add_flow_edge once per hop (work goes from_queue \u2192 to_queue): queues not on the map yet are added automatically, so the edges alone draw the whole process. Use add_flow_queue only for a queue with no arrow. parent_id nests it under another map.",
|
|
10475
|
+
access: "write",
|
|
10476
|
+
inputSchema: object(
|
|
10477
|
+
{ name: str4("Map name, e.g. the process: Checkout", { maxLength: 80 }), description: str4("What the process does", { maxLength: 500 }), parent_id: str4("Manual map to nest it under") },
|
|
10478
|
+
["name"]
|
|
10479
|
+
),
|
|
10480
|
+
annotations: rw(false, false),
|
|
10481
|
+
schema: z10.object({ name: z10.string().trim().min(1).max(80), description: z10.string().max(500).optional(), parent_id: z10.string().min(1).optional() }),
|
|
10482
|
+
run: (a, ctx) => forward(ctx, { method: "POST", url: "/api/flow-maps", body: { name: a.name, description: a.description, parentId: a.parent_id } })
|
|
10483
|
+
}),
|
|
10484
|
+
tool({
|
|
10485
|
+
name: "update_flow_map",
|
|
10486
|
+
title: "Rename or move flow map",
|
|
10487
|
+
description: "Renames a manual flow map, changes its description, or moves it under another map (parent_id; null moves it to the top level). Detected maps are read-only: copy_flow_map them first.",
|
|
10488
|
+
access: "write",
|
|
10489
|
+
inputSchema: object(
|
|
10490
|
+
{
|
|
10491
|
+
map_id: str4("Flow map id"),
|
|
10492
|
+
name: str4("New name", { maxLength: 80 }),
|
|
10493
|
+
description: { type: ["string", "null"], description: "New description; null clears it" },
|
|
10494
|
+
parent_id: { type: ["string", "null"], description: "Map to move it under; null = top level" }
|
|
10495
|
+
},
|
|
10496
|
+
["map_id"]
|
|
10497
|
+
),
|
|
10498
|
+
annotations: rw(false, true),
|
|
10499
|
+
schema: zMap.extend({ name: z10.string().trim().min(1).max(80).optional(), description: z10.string().max(500).nullable().optional(), parent_id: z10.string().min(1).nullable().optional() }),
|
|
10500
|
+
run: (a, ctx) => forward(ctx, { method: "PATCH", url: mapPath(a), body: { name: a.name, description: a.description, parentId: a.parent_id } })
|
|
10501
|
+
}),
|
|
10502
|
+
tool({
|
|
10503
|
+
name: "delete_flow_map",
|
|
10504
|
+
title: "Delete flow map",
|
|
10505
|
+
description: "Deletes a manual flow map and its drawing. Only the drawing: no queue or job is touched. Maps nested under it move up one level.",
|
|
10506
|
+
access: "write",
|
|
10507
|
+
inputSchema: object({ map_id: str4("Flow map id") }, ["map_id"]),
|
|
10508
|
+
annotations: rw(true, true),
|
|
10509
|
+
schema: zMap,
|
|
10510
|
+
run: (a, ctx) => forward(ctx, { method: "DELETE", url: mapPath(a) })
|
|
10511
|
+
}),
|
|
10512
|
+
tool({
|
|
10513
|
+
name: "add_flow_queue",
|
|
10514
|
+
title: "Add queue to flow map",
|
|
10515
|
+
description: "Puts a queue on a manual flow map. Idempotent. Not needed for queues that have an arrow: add_flow_edge adds both ends. The queue does not have to exist yet (it shows as missing). connection_id may be omitted when Bullpane has exactly one connection.",
|
|
10516
|
+
access: "write",
|
|
10517
|
+
inputSchema: object({ map_id: str4("Flow map id"), queue: str4("Queue name"), connection_id: str4("Connection of the queue, from list_connections (optional with a single connection)") }, ["map_id", "queue"]),
|
|
10518
|
+
annotations: rw(false, true),
|
|
10519
|
+
schema: zMap.extend({ queue: z10.string().min(1).max(255), connection_id: z10.string().min(1).optional() }),
|
|
10520
|
+
run: async (a, ctx) => {
|
|
10521
|
+
const conn = await connectionOrDefault(ctx, a.connection_id);
|
|
10522
|
+
if ("error" in conn) return conn.error;
|
|
10523
|
+
return forward(ctx, { method: "POST", url: `${mapPath(a)}/nodes`, body: { connectionId: conn.id, queueName: a.queue } });
|
|
10524
|
+
}
|
|
10525
|
+
}),
|
|
10526
|
+
tool({
|
|
10527
|
+
name: "remove_flow_queue",
|
|
10528
|
+
title: "Remove queue from flow map",
|
|
10529
|
+
description: "Takes a queue off a manual flow map, with the drawn arrows touching it. Only the drawing: the queue itself is untouched. connection_id may be omitted when Bullpane has exactly one connection.",
|
|
10530
|
+
access: "write",
|
|
10531
|
+
inputSchema: object({ map_id: str4("Flow map id"), queue: str4("Queue name"), connection_id: str4("Connection of the queue (optional with a single connection)") }, ["map_id", "queue"]),
|
|
10532
|
+
annotations: rw(true, true),
|
|
10533
|
+
schema: zMap.extend({ queue: z10.string().min(1).max(255), connection_id: z10.string().min(1).optional() }),
|
|
10534
|
+
run: async (a, ctx) => {
|
|
10535
|
+
const conn = await connectionOrDefault(ctx, a.connection_id);
|
|
10536
|
+
if ("error" in conn) return conn.error;
|
|
10537
|
+
return forward(ctx, { method: "DELETE", url: `${mapPath(a)}/nodes/${seg(flowMapNodeId({ connectionId: conn.id, queueName: a.queue }))}` });
|
|
10538
|
+
}
|
|
10539
|
+
}),
|
|
10540
|
+
tool({
|
|
10541
|
+
name: "add_flow_edge",
|
|
10542
|
+
title: "Draw an arrow on a flow map",
|
|
10543
|
+
description: "Draws one hop of a process on a manual flow map: work goes from from_queue to to_queue (the producer side to the consumer side). Call it once per hop; queues not on the map yet are added automatically. Idempotent: drawing the same arrow again only updates its label. The two queues may be on different connections (another Redis, or Postgres): pass from_connection_id and to_connection_id. Each may be omitted only when Bullpane has exactly one connection.",
|
|
10544
|
+
access: "write",
|
|
10545
|
+
inputSchema: object(
|
|
10546
|
+
{
|
|
10547
|
+
map_id: str4("Flow map id"),
|
|
10548
|
+
from_queue: str4("Queue the work comes from"),
|
|
10549
|
+
to_queue: str4("Queue the work goes to"),
|
|
10550
|
+
label: str4("Short text on the arrow, e.g. 'per item' or 'on failure'", { maxLength: 120 }),
|
|
10551
|
+
from_connection_id: str4("Connection of from_queue, from list_connections (optional with a single connection)"),
|
|
10552
|
+
to_connection_id: str4("Connection of to_queue (optional with a single connection)")
|
|
10553
|
+
},
|
|
10554
|
+
["map_id", "from_queue", "to_queue"]
|
|
10555
|
+
),
|
|
10556
|
+
annotations: rw(false, true),
|
|
10557
|
+
schema: zMap.extend({
|
|
10558
|
+
from_queue: z10.string().min(1).max(255),
|
|
10559
|
+
to_queue: z10.string().min(1).max(255),
|
|
10560
|
+
label: z10.string().max(120).optional(),
|
|
10561
|
+
from_connection_id: z10.string().min(1).optional(),
|
|
10562
|
+
to_connection_id: z10.string().min(1).optional()
|
|
10563
|
+
}),
|
|
10564
|
+
run: async (a, ctx) => {
|
|
10565
|
+
let fallback = null;
|
|
10566
|
+
const pick = async (given) => {
|
|
10567
|
+
if (given) return { id: given };
|
|
10568
|
+
fallback ??= await connectionOrDefault(ctx, void 0);
|
|
10569
|
+
return fallback;
|
|
10570
|
+
};
|
|
10571
|
+
const from = await pick(a.from_connection_id);
|
|
10572
|
+
if ("error" in from) return from.error;
|
|
10573
|
+
const to = await pick(a.to_connection_id);
|
|
10574
|
+
if ("error" in to) return to.error;
|
|
10575
|
+
return forward(ctx, {
|
|
10576
|
+
method: "POST",
|
|
10577
|
+
url: `${mapPath(a)}/edges`,
|
|
10578
|
+
body: { from: { connectionId: from.id, queueName: a.from_queue }, to: { connectionId: to.id, queueName: a.to_queue }, label: a.label }
|
|
10579
|
+
});
|
|
10580
|
+
}
|
|
10581
|
+
}),
|
|
10582
|
+
tool({
|
|
10583
|
+
name: "remove_flow_edge",
|
|
10584
|
+
title: "Remove an arrow from a flow map",
|
|
10585
|
+
description: "Removes a drawn arrow (source manual) from a manual flow map; its edge id is in get_flow_map. The queues stay on the map. Detected arrows cannot be removed.",
|
|
10586
|
+
access: "write",
|
|
10587
|
+
inputSchema: object({ map_id: str4("Flow map id"), edge_id: str4("Edge id, from get_flow_map") }, ["map_id", "edge_id"]),
|
|
10588
|
+
annotations: rw(true, true),
|
|
10589
|
+
schema: zMap.extend({ edge_id: z10.string().min(1) }),
|
|
10590
|
+
run: (a, ctx) => forward(ctx, { method: "DELETE", url: `${mapPath(a)}/edges/${seg(a.edge_id)}` })
|
|
10591
|
+
}),
|
|
10592
|
+
tool({
|
|
10593
|
+
name: "copy_flow_map",
|
|
10594
|
+
title: "Copy flow map",
|
|
10595
|
+
description: "Copies a flow map into a new manual one with the same queues and positions (and drawn arrows). This is how a detected map becomes editable; its detected arrows keep being shown live, they are not copied.",
|
|
10596
|
+
access: "write",
|
|
10597
|
+
inputSchema: object({ map_id: str4("Flow map id to copy (manual or detected)"), name: str4("Name of the copy (default: '<name> (copy)')", { maxLength: 80 }), parent_id: str4("Manual map to nest the copy under") }, ["map_id"]),
|
|
10598
|
+
annotations: rw(false, false),
|
|
10599
|
+
schema: zMap.extend({ name: z10.string().trim().min(1).max(80).optional(), parent_id: z10.string().min(1).optional() }),
|
|
10600
|
+
run: (a, ctx) => forward(ctx, { method: "POST", url: `${mapPath(a)}/copy`, body: { name: a.name, parentId: a.parent_id } })
|
|
10601
|
+
}),
|
|
9574
10602
|
// ----- destructive: a link, never an action -----
|
|
9575
10603
|
tool({
|
|
9576
10604
|
name: "request_destructive_action",
|
|
@@ -9786,7 +10814,7 @@ This connection has ${caller.access} access as ${caller.user.email}.`
|
|
|
9786
10814
|
}
|
|
9787
10815
|
|
|
9788
10816
|
// apps/server/src/ee/mcp/store.ts
|
|
9789
|
-
import { and as
|
|
10817
|
+
import { and as and7, desc as desc3, eq as eq10, lt as lt4, notInArray } from "drizzle-orm";
|
|
9790
10818
|
function affectedRows(result) {
|
|
9791
10819
|
const header = Array.isArray(result) ? result[0] : result;
|
|
9792
10820
|
if (typeof header !== "object" || header === null) return 0;
|
|
@@ -9802,7 +10830,7 @@ var DrizzleMcpStore = class {
|
|
|
9802
10830
|
await this.db.insert(mcpClients3).values(row);
|
|
9803
10831
|
}
|
|
9804
10832
|
async getClient(id3) {
|
|
9805
|
-
const rows = await this.db.select().from(mcpClients3).where(
|
|
10833
|
+
const rows = await this.db.select().from(mcpClients3).where(eq10(mcpClients3.id, id3)).limit(1);
|
|
9806
10834
|
return rows[0] ?? null;
|
|
9807
10835
|
}
|
|
9808
10836
|
async countClients() {
|
|
@@ -9810,16 +10838,16 @@ var DrizzleMcpStore = class {
|
|
|
9810
10838
|
}
|
|
9811
10839
|
async purgeUnusedClients(before) {
|
|
9812
10840
|
const used = this.db.selectDistinct({ id: mcpGrants3.clientId }).from(mcpGrants3);
|
|
9813
|
-
await this.db.delete(mcpClients3).where(
|
|
10841
|
+
await this.db.delete(mcpClients3).where(and7(lt4(mcpClients3.createdAt, before), notInArray(mcpClients3.id, used)));
|
|
9814
10842
|
}
|
|
9815
10843
|
async insertCode(row) {
|
|
9816
10844
|
await this.db.insert(mcpAuthCodes3).values(row);
|
|
9817
10845
|
}
|
|
9818
10846
|
async consumeCode(codeHash) {
|
|
9819
|
-
const rows = await this.db.select().from(mcpAuthCodes3).where(
|
|
10847
|
+
const rows = await this.db.select().from(mcpAuthCodes3).where(eq10(mcpAuthCodes3.codeHash, codeHash)).limit(1);
|
|
9820
10848
|
const row = rows[0];
|
|
9821
10849
|
if (!row) return null;
|
|
9822
|
-
const res = await this.db.delete(mcpAuthCodes3).where(
|
|
10850
|
+
const res = await this.db.delete(mcpAuthCodes3).where(eq10(mcpAuthCodes3.codeHash, codeHash));
|
|
9823
10851
|
return affectedRows(res) === 1 ? row : null;
|
|
9824
10852
|
}
|
|
9825
10853
|
async purgeExpiredCodes(now) {
|
|
@@ -9829,40 +10857,40 @@ var DrizzleMcpStore = class {
|
|
|
9829
10857
|
await this.db.insert(mcpGrants3).values(row);
|
|
9830
10858
|
}
|
|
9831
10859
|
async findGrantByRefresh(refreshHash) {
|
|
9832
|
-
const rows = await this.db.select().from(mcpGrants3).where(
|
|
10860
|
+
const rows = await this.db.select().from(mcpGrants3).where(eq10(mcpGrants3.refreshHash, refreshHash)).limit(1);
|
|
9833
10861
|
return rows[0] ?? null;
|
|
9834
10862
|
}
|
|
9835
10863
|
async findGrantByPrevRefresh(refreshHash) {
|
|
9836
|
-
const rows = await this.db.select().from(mcpGrants3).where(
|
|
10864
|
+
const rows = await this.db.select().from(mcpGrants3).where(eq10(mcpGrants3.prevRefreshHash, refreshHash)).limit(1);
|
|
9837
10865
|
return rows[0] ?? null;
|
|
9838
10866
|
}
|
|
9839
10867
|
async rotateRefresh(grantId, oldHash, newHash, expiresAt) {
|
|
9840
|
-
const res = await this.db.update(mcpGrants3).set({ refreshHash: newHash, prevRefreshHash: oldHash, refreshExpiresAt: expiresAt }).where(
|
|
10868
|
+
const res = await this.db.update(mcpGrants3).set({ refreshHash: newHash, prevRefreshHash: oldHash, refreshExpiresAt: expiresAt }).where(and7(eq10(mcpGrants3.id, grantId), eq10(mcpGrants3.refreshHash, oldHash)));
|
|
9841
10869
|
return affectedRows(res) === 1;
|
|
9842
10870
|
}
|
|
9843
10871
|
async getGrantWithUser(grantId) {
|
|
9844
|
-
const rows = await this.db.select({ grant: mcpGrants3, user: users3, clientName: mcpClients3.name, redirectUris: mcpClients3.redirectUris }).from(mcpGrants3).innerJoin(users3,
|
|
10872
|
+
const rows = await this.db.select({ grant: mcpGrants3, user: users3, clientName: mcpClients3.name, redirectUris: mcpClients3.redirectUris }).from(mcpGrants3).innerJoin(users3, eq10(mcpGrants3.userId, users3.id)).innerJoin(mcpClients3, eq10(mcpGrants3.clientId, mcpClients3.id)).where(eq10(mcpGrants3.id, grantId)).limit(1);
|
|
9845
10873
|
return rows[0] ?? null;
|
|
9846
10874
|
}
|
|
9847
10875
|
async touchGrant(grantId, at) {
|
|
9848
|
-
await this.db.update(mcpGrants3).set({ lastUsedAt: at }).where(
|
|
10876
|
+
await this.db.update(mcpGrants3).set({ lastUsedAt: at }).where(eq10(mcpGrants3.id, grantId));
|
|
9849
10877
|
}
|
|
9850
10878
|
async deleteGrant(grantId) {
|
|
9851
|
-
await this.db.delete(mcpGrants3).where(
|
|
10879
|
+
await this.db.delete(mcpGrants3).where(eq10(mcpGrants3.id, grantId));
|
|
9852
10880
|
}
|
|
9853
10881
|
async deleteGrantsForUser(userId) {
|
|
9854
|
-
await this.db.delete(mcpGrants3).where(
|
|
10882
|
+
await this.db.delete(mcpGrants3).where(eq10(mcpGrants3.userId, userId));
|
|
9855
10883
|
}
|
|
9856
10884
|
async listGrants(userId) {
|
|
9857
|
-
const q = this.db.select({ grant: mcpGrants3, user: users3, clientName: mcpClients3.name, redirectUris: mcpClients3.redirectUris }).from(mcpGrants3).innerJoin(users3,
|
|
9858
|
-
const rows = await (userId ? q.where(
|
|
10885
|
+
const q = this.db.select({ grant: mcpGrants3, user: users3, clientName: mcpClients3.name, redirectUris: mcpClients3.redirectUris }).from(mcpGrants3).innerJoin(users3, eq10(mcpGrants3.userId, users3.id)).innerJoin(mcpClients3, eq10(mcpGrants3.clientId, mcpClients3.id));
|
|
10886
|
+
const rows = await (userId ? q.where(eq10(mcpGrants3.userId, userId)) : q).orderBy(desc3(mcpGrants3.createdAt)).limit(500);
|
|
9859
10887
|
return rows;
|
|
9860
10888
|
}
|
|
9861
10889
|
};
|
|
9862
10890
|
|
|
9863
10891
|
// apps/server/src/services/users.ts
|
|
9864
|
-
import { and as
|
|
9865
|
-
import { nanoid as
|
|
10892
|
+
import { and as and8, eq as eq11, isNull as isNull3 } from "drizzle-orm";
|
|
10893
|
+
import { nanoid as nanoid9 } from "nanoid";
|
|
9866
10894
|
var UsersService = class {
|
|
9867
10895
|
constructor(db) {
|
|
9868
10896
|
this.db = db;
|
|
@@ -9875,13 +10903,13 @@ var UsersService = class {
|
|
|
9875
10903
|
return rows.map(toUserDto);
|
|
9876
10904
|
}
|
|
9877
10905
|
async getRow(id3) {
|
|
9878
|
-
const rows = await this.db.select().from(users3).where(
|
|
10906
|
+
const rows = await this.db.select().from(users3).where(eq11(users3.id, id3)).limit(1);
|
|
9879
10907
|
const row = rows[0];
|
|
9880
10908
|
if (!row) throw notFound("User");
|
|
9881
10909
|
return row;
|
|
9882
10910
|
}
|
|
9883
10911
|
async findByEmail(email) {
|
|
9884
|
-
const rows = await this.db.select().from(users3).where(
|
|
10912
|
+
const rows = await this.db.select().from(users3).where(eq11(users3.email, email.toLowerCase())).limit(1);
|
|
9885
10913
|
return rows[0] ?? null;
|
|
9886
10914
|
}
|
|
9887
10915
|
async create(input) {
|
|
@@ -9890,7 +10918,7 @@ var UsersService = class {
|
|
|
9890
10918
|
if (existing) {
|
|
9891
10919
|
throw conflict(existing.disabledAt ? "A disabled user has this email. Re-enable that account instead of creating a new one." : "A user with this email already exists");
|
|
9892
10920
|
}
|
|
9893
|
-
const id3 =
|
|
10921
|
+
const id3 = nanoid9();
|
|
9894
10922
|
await this.db.insert(users3).values({
|
|
9895
10923
|
id: id3,
|
|
9896
10924
|
email,
|
|
@@ -9922,16 +10950,16 @@ var UsersService = class {
|
|
|
9922
10950
|
patch.disabledAt = input.disabled ? /* @__PURE__ */ new Date() : null;
|
|
9923
10951
|
}
|
|
9924
10952
|
if (Object.keys(patch).length > 0) {
|
|
9925
|
-
await this.db.update(users3).set(patch).where(
|
|
10953
|
+
await this.db.update(users3).set(patch).where(eq11(users3.id, id3));
|
|
9926
10954
|
}
|
|
9927
10955
|
return toUserDto(await this.getRow(id3));
|
|
9928
10956
|
}
|
|
9929
10957
|
async touchLogin(id3, at = /* @__PURE__ */ new Date()) {
|
|
9930
|
-
await this.db.update(users3).set({ lastLoginAt: at }).where(
|
|
10958
|
+
await this.db.update(users3).set({ lastLoginAt: at }).where(eq11(users3.id, id3));
|
|
9931
10959
|
}
|
|
9932
10960
|
/** Active admins only: a disabled admin cannot log in, so it does not keep the install manageable. */
|
|
9933
10961
|
async adminCount() {
|
|
9934
|
-
return (await this.db.select({ id: users3.id }).from(users3).where(
|
|
10962
|
+
return (await this.db.select({ id: users3.id }).from(users3).where(and8(eq11(users3.role, "admin"), isNull3(users3.disabledAt)))).length;
|
|
9935
10963
|
}
|
|
9936
10964
|
};
|
|
9937
10965
|
|
|
@@ -9971,6 +10999,7 @@ async function buildApp(opts) {
|
|
|
9971
10999
|
const health = new HealthService(connections5);
|
|
9972
11000
|
connections5.onEvict((id3) => health.evict(id3));
|
|
9973
11001
|
const flows = new FlowsService(db, connections5);
|
|
11002
|
+
const flowMaps4 = new FlowMapsService(db, connections5, flows);
|
|
9974
11003
|
const alerts4 = new AlertsService(db, connections5, folders4);
|
|
9975
11004
|
const audit = new AuditService(db, app.log);
|
|
9976
11005
|
const attention = new AttentionService(new DrizzleSettingsStore(db));
|
|
@@ -9987,6 +11016,7 @@ async function buildApp(opts) {
|
|
|
9987
11016
|
folders: folders4,
|
|
9988
11017
|
health,
|
|
9989
11018
|
flows,
|
|
11019
|
+
flowMaps: flowMaps4,
|
|
9990
11020
|
alerts: alerts4,
|
|
9991
11021
|
alertsEngine,
|
|
9992
11022
|
audit,
|