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/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
- var flowsQuerySchema = z4.object({ sample: z4.coerce.number().int().min(10).max(2e3).default(200) });
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 nanoid2 } from "nanoid";
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 = nanoid2();
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 and3, asc, desc as desc2, eq as eq3, gte, lt as lt3, lte, or, sql } from "drizzle-orm";
7533
- import { nanoid as nanoid3 } from "nanoid";
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: nanoid3(),
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(eq3(auditLog3.actorId, query.actorId));
7650
- if (query.action) filters.push(eq3(auditLog3.action, query.action));
7651
- if (query.connectionId) filters.push(eq3(auditLog3.connectionId, query.connectionId));
7652
- if (query.queueName) filters.push(eq3(auditLog3.queueName, query.queueName));
7653
- if (query.jobId) filters.push(eq3(auditLog3.jobId, query.jobId));
7654
- if (query.result) filters.push(eq3(auditLog3.result, query.result));
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), and3(eq3(auditLog3.createdAt, c.createdAt), lt3(auditLog3.id, c.id)));
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] : and3(...filters);
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 and4, eq as eq4, inArray } from "drizzle-orm";
7730
- import { nanoid as nanoid4 } from "nanoid";
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(eq4(connections3.id, id3)).limit(1);
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 = nanoid4();
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(eq4(connections3.id, id3));
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(eq4(connections3.id, id3));
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(eq4(folderQueues3.connectionId, id3));
7865
- await this.db.delete(hiddenQueues3).where(eq4(hiddenQueues3.connectionId, id3));
7866
- await this.db.delete(alerts3).where(eq4(alerts3.connectionId, id3));
7867
- await this.db.delete(flowEdges3).where(eq4(flowEdges3.connectionId, id3));
7868
- await this.db.delete(connections3).where(eq4(connections3.id, id3));
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(eq4(hiddenQueues3.connectionId, connectionId));
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(eq4(hiddenQueues3.connectionId, connectionId)).orderBy(hiddenQueues3.hiddenAt);
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(and4(eq4(hiddenQueues3.connectionId, connectionId), eq4(hiddenQueues3.queueName, queueName))).limit(1);
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(and4(eq4(hiddenQueues3.connectionId, connectionId), eq4(hiddenQueues3.queueName, queueName)));
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(and4(eq4(hiddenQueues3.connectionId, connectionId), eq4(hiddenQueues3.queueName, queueName))).limit(1);
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(and4(eq4(connections3.name, name))).limit(1);
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 eq5 } from "drizzle-orm";
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(eq5(settings3.key, key)).limit(1);
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(eq5(settings3.key, key));
9103
+ await this.db.delete(settings3).where(eq6(settings3.key, key));
8625
9104
  }
8626
9105
  };
8627
9106
 
8628
- // apps/server/src/ee/services/flows.ts
8629
- import { and as and5, eq as eq6 } from "drizzle-orm";
8630
- import { nanoid as nanoid5 } from "nanoid";
8631
- var FLOW_CACHE_TTL_MS = 3e4;
8632
- var FLOW_CONCURRENCY = 5;
8633
- function detectedEdgeId(from, to) {
8634
- return `d:${from}->${to}`;
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 toFlowEdgeDto(row) {
8637
- return { id: row.id, from: row.fromQueue, to: row.toQueue, source: "manual", evidence: 0, label: row.label ?? null };
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
- async function mapWithConcurrency(items, limit, fn) {
8640
- const results = new Array(items.length);
8641
- let next = 0;
8642
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
8643
- while (next < items.length) {
8644
- const index3 = next++;
8645
- results[index3] = await fn(items[index3]);
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
- var FlowsService = class {
8652
- constructor(db, connections5) {
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
- cache = /* @__PURE__ */ new Map();
8657
- async getGraph(connectionId, sample) {
8658
- const row = await this.connections.getRow(connectionId);
8659
- const [detected, manual] = await Promise.all([this.detected(row, sample), this.manualEdges(connectionId)]);
8660
- const nodeIds = new Set(detected.nodes.map((n) => n.id));
8661
- const nodes = [...detected.nodes];
8662
- for (const edge of manual) {
8663
- for (const name of [edge.from, edge.to]) {
8664
- if (!nodeIds.has(name)) {
8665
- nodeIds.add(name);
8666
- nodes.push({ id: name, queueName: name, counts: { ...EMPTY_COUNTS }, isPaused: false });
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
- connectionId,
8672
- nodes,
8673
- edges: [...detected.edges, ...manual],
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
- invalidate(connectionId) {
8678
- this.cache.delete(connectionId);
8679
- }
8680
- detected(row, sample) {
8681
- const cached = this.cache.get(row.id);
8682
- const now = Date.now();
8683
- if (cached && cached.sample === sample && now - cached.at < FLOW_CACHE_TTL_MS) return cached.value;
8684
- const value = withRedis(() => this.computeDetected(this.connections.inspectorFor(row), sample));
8685
- this.cache.set(row.id, { at: now, sample, value });
8686
- value.catch(() => this.cache.delete(row.id));
8687
- return value;
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
- async computeDetected(inspector, sample) {
8690
- const names = await inspector.discoverQueues();
8691
- if (names.length === 0) return { nodes: [], edges: [], sampledJobs: 0 };
8692
- const stats = await inspector.getQueueStats(names);
8693
- const nodes = names.map((name) => ({
8694
- id: name,
8695
- queueName: name,
8696
- counts: stats[name]?.counts ?? { ...EMPTY_COUNTS },
8697
- isPaused: stats[name]?.isPaused ?? false
8698
- }));
8699
- const samples = await mapWithConcurrency(
8700
- names,
8701
- FLOW_CONCURRENCY,
8702
- (name) => inspector.sampleFlowEdges(name, { sample })
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 edgeMap = /* @__PURE__ */ new Map();
8705
- let sampledJobs = 0;
8706
- for (const result of samples) {
8707
- sampledJobs += result.sampled;
8708
- for (const e of result.edges) {
8709
- const id3 = detectedEdgeId(e.childQueue, e.parentQueue);
8710
- const existing = edgeMap.get(id3);
8711
- if (existing) {
8712
- existing.evidence += e.count;
8713
- } else {
8714
- edgeMap.set(id3, {
8715
- id: id3,
8716
- from: e.childQueue,
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 manualEdges(connectionId) {
8728
- const rows = await this.db.select().from(flowEdges3).where(eq6(flowEdges3.connectionId, connectionId));
8729
- return rows.map(toFlowEdgeDto);
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
- async createManualEdge(input) {
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
- if (input.from === input.to) throw conflict("A queue cannot flow into itself");
8734
- const existing = await this.db.select({ id: flowEdges3.id }).from(flowEdges3).where(
8735
- and5(
8736
- eq6(flowEdges3.connectionId, input.connectionId),
8737
- eq6(flowEdges3.fromQueue, input.from),
8738
- eq6(flowEdges3.toQueue, input.to)
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
- ).limit(1);
8741
- if (existing[0]) throw conflict("This edge already exists");
8742
- const id3 = nanoid5();
8743
- await this.db.insert(flowEdges3).values({
8744
- id: id3,
8745
- connectionId: input.connectionId,
8746
- fromQueue: input.from,
8747
- toQueue: input.to,
8748
- label: input.label ?? null,
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
- const rows = await this.db.select().from(flowEdges3).where(eq6(flowEdges3.id, id3)).limit(1);
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 toFlowEdgeDto(row);
9555
+ if (!row) throw notFound("Flow map edge");
9556
+ return row;
8755
9557
  }
8756
- async deleteManualEdge(id3) {
8757
- const rows = await this.db.select({ id: flowEdges3.id }).from(flowEdges3).where(eq6(flowEdges3.id, id3)).limit(1);
8758
- if (!rows[0]) throw notFound("Flow edge");
8759
- await this.db.delete(flowEdges3).where(eq6(flowEdges3.id, id3));
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 eq7, inArray as inArray2 } from "drizzle-orm";
8765
- import { nanoid as nanoid6 } from "nanoid";
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
- inArray2(
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(eq7(folders3.id, id3)).limit(1);
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(eq7(folderQueues3.folderId, id3));
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 = nanoid6();
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(eq7(folders3.id, id3));
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(eq7(folders3.parentId, id3));
8832
- await this.db.delete(folderQueues3).where(eq7(folderQueues3.folderId, id3));
8833
- await this.db.delete(folders3).where(eq7(folders3.id, id3));
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(eq7(folderQueues3.folderId, id3));
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 eq8 } from "drizzle-orm";
9054
- import { nanoid as nanoid7 } from "nanoid";
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(eq8(ssoProviders3.id, id3)).limit(1);
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 = nanoid7();
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(eq8(ssoProviders3.id, id3));
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(eq8(ssoProviders3.enabled, true))).filter((r) => r.id !== id3);
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(eq8(ssoProviders3.id, id3));
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(eq8(ssoProviders3.enabled, true));
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(eq8(ssoProviders3.enabled, true)).orderBy(asc2(ssoProviders3.createdAt));
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(eq8(users3.email, normalised)).limit(1);
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 and6, desc as desc3, eq as eq9, lt as lt4, notInArray } from "drizzle-orm";
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(eq9(mcpClients3.id, id3)).limit(1);
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(and6(lt4(mcpClients3.createdAt, before), notInArray(mcpClients3.id, used)));
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(eq9(mcpAuthCodes3.codeHash, codeHash)).limit(1);
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(eq9(mcpAuthCodes3.codeHash, codeHash));
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(eq9(mcpGrants3.refreshHash, refreshHash)).limit(1);
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(eq9(mcpGrants3.prevRefreshHash, refreshHash)).limit(1);
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(and6(eq9(mcpGrants3.id, grantId), eq9(mcpGrants3.refreshHash, oldHash)));
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, eq9(mcpGrants3.userId, users3.id)).innerJoin(mcpClients3, eq9(mcpGrants3.clientId, mcpClients3.id)).where(eq9(mcpGrants3.id, grantId)).limit(1);
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(eq9(mcpGrants3.id, grantId));
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(eq9(mcpGrants3.id, grantId));
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(eq9(mcpGrants3.userId, userId));
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, eq9(mcpGrants3.userId, users3.id)).innerJoin(mcpClients3, eq9(mcpGrants3.clientId, mcpClients3.id));
9858
- const rows = await (userId ? q.where(eq9(mcpGrants3.userId, userId)) : q).orderBy(desc3(mcpGrants3.createdAt)).limit(500);
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 and7, eq as eq10, isNull as isNull2 } from "drizzle-orm";
9865
- import { nanoid as nanoid8 } from "nanoid";
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(eq10(users3.id, id3)).limit(1);
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(eq10(users3.email, email.toLowerCase())).limit(1);
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 = nanoid8();
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(eq10(users3.id, id3));
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(eq10(users3.id, id3));
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(and7(eq10(users3.role, "admin"), isNull2(users3.disabledAt)))).length;
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,