@csdwd/ai-teams-server 0.1.3 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +387 -2
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs";
7
+ import { randomUUID as randomUUID2 } from "node:crypto";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import Fastify from "fastify";
9
10
  import swagger from "@fastify/swagger";
@@ -373,6 +374,25 @@ async function initDb(db) {
373
374
  webhook_url TEXT NOT NULL
374
375
  )
375
376
  `);
377
+ await db.run(`
378
+ CREATE TABLE IF NOT EXISTS schedules (
379
+ id TEXT PRIMARY KEY,
380
+ name TEXT NOT NULL,
381
+ cron_expr TEXT NOT NULL,
382
+ enabled INTEGER NOT NULL DEFAULT 1,
383
+ target_mode TEXT NOT NULL DEFAULT 'queue',
384
+ target_agents TEXT NOT NULL DEFAULT '[]',
385
+ prompt TEXT NOT NULL,
386
+ workspace TEXT,
387
+ timeout_sec INTEGER,
388
+ priority INTEGER NOT NULL DEFAULT 0,
389
+ required_labels TEXT DEFAULT '[]',
390
+ last_run_at TEXT,
391
+ next_run_at TEXT,
392
+ created_at TEXT NOT NULL,
393
+ updated_at TEXT NOT NULL
394
+ )
395
+ `);
376
396
  }
377
397
  async function hydrateState(db, state, defaultTimeoutSec, maxLogChunksPerTask) {
378
398
  const employeeRows = await db.all("SELECT payload_json FROM employees");
@@ -609,6 +629,90 @@ function taskToDbValues(task) {
609
629
  function toSnakeCase(str) {
610
630
  return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
611
631
  }
632
+ var SCHEDULE_COLUMNS = [
633
+ "id",
634
+ "name",
635
+ "cron_expr",
636
+ "enabled",
637
+ "target_mode",
638
+ "target_agents",
639
+ "prompt",
640
+ "workspace",
641
+ "timeout_sec",
642
+ "priority",
643
+ "required_labels",
644
+ "last_run_at",
645
+ "next_run_at",
646
+ "created_at",
647
+ "updated_at"
648
+ ];
649
+ var SCHEDULE_PLACEHOLDERS = SCHEDULE_COLUMNS.map((_, i) => `$${i + 1}`).join(", ");
650
+ var SCHEDULE_UPDATE_SET = SCHEDULE_COLUMNS.slice(1).map((col) => `${col} = excluded.${col}`).join(", ");
651
+ function dbRowToSchedule(row) {
652
+ return {
653
+ id: row.id,
654
+ name: row.name,
655
+ cronExpr: row.cron_expr,
656
+ enabled: row.enabled === 1,
657
+ targetMode: row.target_mode,
658
+ targetAgents: JSON.parse(row.target_agents || "[]"),
659
+ prompt: row.prompt,
660
+ workspace: row.workspace,
661
+ timeoutSec: row.timeout_sec,
662
+ priority: row.priority,
663
+ requiredLabels: row.required_labels ? JSON.parse(row.required_labels) : null,
664
+ lastRunAt: row.last_run_at,
665
+ nextRunAt: row.next_run_at,
666
+ createdAt: row.created_at,
667
+ updatedAt: row.updated_at
668
+ };
669
+ }
670
+ function scheduleToDbValues(s) {
671
+ return [
672
+ s.id,
673
+ s.name,
674
+ s.cronExpr,
675
+ s.enabled ? 1 : 0,
676
+ s.targetMode,
677
+ JSON.stringify(s.targetAgents),
678
+ s.prompt,
679
+ s.workspace,
680
+ s.timeoutSec,
681
+ s.priority,
682
+ s.requiredLabels ? JSON.stringify(s.requiredLabels) : null,
683
+ s.lastRunAt,
684
+ s.nextRunAt,
685
+ s.createdAt,
686
+ s.updatedAt
687
+ ];
688
+ }
689
+ async function upsertSchedule(db, schedule) {
690
+ await db.run(
691
+ `INSERT INTO schedules (${SCHEDULE_COLUMNS.join(", ")}) VALUES (${SCHEDULE_PLACEHOLDERS}) ON CONFLICT(id) DO UPDATE SET ${SCHEDULE_UPDATE_SET}`,
692
+ scheduleToDbValues(schedule)
693
+ );
694
+ }
695
+ async function getAllSchedules(db) {
696
+ const rows = await db.all("SELECT * FROM schedules ORDER BY created_at");
697
+ return rows.map(dbRowToSchedule);
698
+ }
699
+ async function getScheduleById(db, id) {
700
+ const row = await db.get("SELECT * FROM schedules WHERE id = $1", [id]);
701
+ return row ? dbRowToSchedule(row) : void 0;
702
+ }
703
+ async function deleteScheduleRow(db, id) {
704
+ const row = await db.get("SELECT id FROM schedules WHERE id = $1", [id]);
705
+ if (!row) return false;
706
+ await db.run("DELETE FROM schedules WHERE id = $1", [id]);
707
+ return true;
708
+ }
709
+ async function updateScheduleFields(db, id, fields) {
710
+ const existing = await getScheduleById(db, id);
711
+ if (!existing) return void 0;
712
+ const updated = { ...existing, ...fields, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
713
+ await upsertSchedule(db, updated);
714
+ return updated;
715
+ }
612
716
 
613
717
  // src/schemas.ts
614
718
  var errorResponseSchema = {
@@ -890,6 +994,65 @@ function parseWebhookUrl(value) {
890
994
  }
891
995
  return url.toString();
892
996
  }
997
+ var createScheduleRequestSchema = {
998
+ type: "object",
999
+ required: ["name", "cron", "prompt"],
1000
+ properties: {
1001
+ name: { type: "string", minLength: 1 },
1002
+ cron: { type: "string", minLength: 9 },
1003
+ enabled: { type: "boolean", default: true },
1004
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"], default: "queue" },
1005
+ targetAgents: { type: "array", items: { type: "string" } },
1006
+ prompt: { type: "string", minLength: 1 },
1007
+ workspace: { type: "string" },
1008
+ timeoutSec: { type: "number", minimum: 1 },
1009
+ priority: { type: "integer", minimum: 0, maximum: 3, default: 0 },
1010
+ requiredLabels: { type: "array", items: { type: "string" } }
1011
+ }
1012
+ };
1013
+ var updateScheduleRequestSchema = {
1014
+ type: "object",
1015
+ properties: {
1016
+ name: { type: "string", minLength: 1 },
1017
+ cron: { type: "string", minLength: 9 },
1018
+ enabled: { type: "boolean" },
1019
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"] },
1020
+ targetAgents: { type: "array", items: { type: "string" } },
1021
+ prompt: { type: "string", minLength: 1 },
1022
+ workspace: { type: "string" },
1023
+ timeoutSec: { type: "number", minimum: 1 },
1024
+ priority: { type: "integer", minimum: 0, maximum: 3 },
1025
+ requiredLabels: { type: "array", items: { type: "string" } }
1026
+ }
1027
+ };
1028
+ var scheduleResponseSchema = {
1029
+ type: "object",
1030
+ required: ["id", "name", "cron", "enabled", "targetMode", "prompt", "createdAt", "updatedAt"],
1031
+ properties: {
1032
+ id: { type: "string" },
1033
+ name: { type: "string" },
1034
+ cron: { type: "string" },
1035
+ enabled: { type: "boolean" },
1036
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"] },
1037
+ targetAgents: { type: "array", items: { type: "string" } },
1038
+ prompt: { type: "string" },
1039
+ workspace: { type: "string" },
1040
+ timeoutSec: { type: "number" },
1041
+ priority: { type: "integer" },
1042
+ requiredLabels: { type: "array", items: { type: "string" } },
1043
+ lastRunAt: { type: "string" },
1044
+ nextRunAt: { type: "string" },
1045
+ createdAt: { type: "string" },
1046
+ updatedAt: { type: "string" }
1047
+ }
1048
+ };
1049
+ var scheduleListResponseSchema = {
1050
+ type: "object",
1051
+ required: ["schedules"],
1052
+ properties: {
1053
+ schedules: { type: "array", items: scheduleResponseSchema }
1054
+ }
1055
+ };
893
1056
 
894
1057
  // src/dispatch.ts
895
1058
  import { createHmac, randomUUID } from "node:crypto";
@@ -1655,7 +1818,8 @@ function createInMemoryStateStore() {
1655
1818
  taskQueues: /* @__PURE__ */ new Map(),
1656
1819
  mainTaskQueues: /* @__PURE__ */ new Map(),
1657
1820
  sharedTaskQueue: [],
1658
- sharedQueueCursor: 0
1821
+ sharedQueueCursor: 0,
1822
+ scheduleJobs: /* @__PURE__ */ new Map()
1659
1823
  };
1660
1824
  }
1661
1825
 
@@ -1705,6 +1869,41 @@ function createEncryptor(encryptionKeyHex) {
1705
1869
  };
1706
1870
  }
1707
1871
 
1872
+ // src/scheduler.ts
1873
+ import { CronJob } from "cron";
1874
+ function startScheduleJob(schedule, dispatchFn, jobs, onFire) {
1875
+ const atAgents = schedule.targetMode === "queue" ? "queue" : schedule.targetMode === "broadcast" ? "all" : schedule.targetAgents;
1876
+ const job = new CronJob(
1877
+ schedule.cronExpr,
1878
+ () => {
1879
+ dispatchFn(
1880
+ {
1881
+ type: "command.dispatch",
1882
+ atAgents,
1883
+ prompt: schedule.prompt,
1884
+ workspace: schedule.workspace ?? void 0,
1885
+ timeoutSec: schedule.timeoutSec ?? void 0
1886
+ },
1887
+ null,
1888
+ void 0,
1889
+ schedule.priority,
1890
+ schedule.requiredLabels
1891
+ );
1892
+ onFire(schedule.id);
1893
+ },
1894
+ null,
1895
+ true
1896
+ );
1897
+ jobs.set(schedule.id, job);
1898
+ }
1899
+ function stopScheduleJob(jobs, scheduleId) {
1900
+ const job = jobs.get(scheduleId);
1901
+ if (job) {
1902
+ job.stop();
1903
+ jobs.delete(scheduleId);
1904
+ }
1905
+ }
1906
+
1708
1907
  // src/index.ts
1709
1908
  var DEFAULT_PORT = 3789;
1710
1909
  function isAuthorized(authToken, rawUrl, headers) {
@@ -1794,6 +1993,29 @@ async function createAiTeamsServer(options) {
1794
1993
  encryptor: createEncryptor(process.env.AI_TEAMS_ENCRYPTION_KEY)
1795
1994
  };
1796
1995
  const { dispatchLeaderCommand, handleAgentMessage, handleLeaderMessage, cancelTaskById } = createDispatch(dispatchCtx);
1996
+ const scheduleDispatchFn = (message, webhookUrl, cliConfig, priority, requiredLabels) => {
1997
+ return dispatchLeaderCommand(message, webhookUrl, cliConfig, priority, requiredLabels);
1998
+ };
1999
+ async function onScheduleFire(scheduleId) {
2000
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2001
+ const job = state.scheduleJobs.get(scheduleId);
2002
+ const nextRun = job?.nextDate()?.toISO() ?? null;
2003
+ await updateScheduleFields(db, scheduleId, { lastRunAt: now, nextRunAt: nextRun });
2004
+ }
2005
+ async function loadAndStartSchedules() {
2006
+ const schedules = await getAllSchedules(db);
2007
+ for (const schedule of schedules) {
2008
+ if (schedule.enabled) {
2009
+ try {
2010
+ startScheduleJob(schedule, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2011
+ } catch (err) {
2012
+ app.log.error({ scheduleId: schedule.id, cronExpr: schedule.cronExpr, err }, "Failed to start schedule");
2013
+ }
2014
+ }
2015
+ }
2016
+ app.log.info({ count: schedules.filter((s) => s.enabled).length }, "Schedules loaded");
2017
+ }
2018
+ await loadAndStartSchedules();
1797
2019
  function buildSnapshot() {
1798
2020
  return {
1799
2021
  employees: [...state.employees.values()],
@@ -2204,6 +2426,163 @@ async function createAiTeamsServer(options) {
2204
2426
  return { deleted: true };
2205
2427
  }
2206
2428
  );
2429
+ app.get(
2430
+ "/api/schedules",
2431
+ {
2432
+ schema: {
2433
+ tags: ["schedules"],
2434
+ summary: "List all schedules",
2435
+ response: { 200: scheduleListResponseSchema, 401: errorResponseSchema }
2436
+ }
2437
+ },
2438
+ async () => {
2439
+ const schedules = await getAllSchedules(db);
2440
+ return { schedules };
2441
+ }
2442
+ );
2443
+ app.get(
2444
+ "/api/schedules/:scheduleId",
2445
+ {
2446
+ schema: {
2447
+ tags: ["schedules"],
2448
+ summary: "Get a schedule by ID",
2449
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2450
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2451
+ }
2452
+ },
2453
+ async (request, reply) => {
2454
+ const schedule = await getScheduleById(db, request.params.scheduleId);
2455
+ if (!schedule) return reply.code(404).send({ error: "Schedule not found." });
2456
+ return schedule;
2457
+ }
2458
+ );
2459
+ app.post(
2460
+ "/api/schedules",
2461
+ {
2462
+ schema: {
2463
+ tags: ["schedules"],
2464
+ summary: "Create a schedule",
2465
+ body: createScheduleRequestSchema,
2466
+ response: { 201: scheduleResponseSchema, 400: errorResponseSchema, 401: errorResponseSchema }
2467
+ }
2468
+ },
2469
+ async (request, reply) => {
2470
+ const body = request.body;
2471
+ const id = randomUUID2();
2472
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2473
+ const schedule = {
2474
+ id,
2475
+ name: body.name,
2476
+ cronExpr: body.cron,
2477
+ enabled: body.enabled ?? true,
2478
+ targetMode: body.targetMode ?? "queue",
2479
+ targetAgents: body.targetAgents ?? [],
2480
+ prompt: body.prompt,
2481
+ workspace: body.workspace ?? null,
2482
+ timeoutSec: body.timeoutSec ?? null,
2483
+ priority: body.priority ?? 0,
2484
+ requiredLabels: body.requiredLabels ?? null,
2485
+ lastRunAt: null,
2486
+ nextRunAt: null,
2487
+ createdAt: now,
2488
+ updatedAt: now
2489
+ };
2490
+ try {
2491
+ if (schedule.enabled) {
2492
+ startScheduleJob(schedule, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2493
+ schedule.nextRunAt = state.scheduleJobs.get(id)?.nextDate()?.toISO() ?? null;
2494
+ }
2495
+ await upsertSchedule(db, schedule);
2496
+ return reply.code(201).send(schedule);
2497
+ } catch (err) {
2498
+ stopScheduleJob(state.scheduleJobs, id);
2499
+ return reply.code(400).send({ error: err instanceof Error ? err.message : "Invalid schedule." });
2500
+ }
2501
+ }
2502
+ );
2503
+ app.patch(
2504
+ "/api/schedules/:scheduleId",
2505
+ {
2506
+ schema: {
2507
+ tags: ["schedules"],
2508
+ summary: "Update a schedule",
2509
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2510
+ body: updateScheduleRequestSchema,
2511
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2512
+ }
2513
+ },
2514
+ async (request, reply) => {
2515
+ const existing = await getScheduleById(db, request.params.scheduleId);
2516
+ if (!existing) return reply.code(404).send({ error: "Schedule not found." });
2517
+ const fields = {};
2518
+ if (request.body.name !== void 0) fields.name = request.body.name;
2519
+ if (request.body.cron !== void 0) fields.cronExpr = request.body.cron;
2520
+ if (request.body.enabled !== void 0) fields.enabled = request.body.enabled;
2521
+ if (request.body.targetMode !== void 0) fields.targetMode = request.body.targetMode;
2522
+ if (request.body.targetAgents !== void 0) fields.targetAgents = request.body.targetAgents;
2523
+ if (request.body.prompt !== void 0) fields.prompt = request.body.prompt;
2524
+ if (request.body.workspace !== void 0) fields.workspace = request.body.workspace;
2525
+ if (request.body.timeoutSec !== void 0) fields.timeoutSec = request.body.timeoutSec;
2526
+ if (request.body.priority !== void 0) fields.priority = request.body.priority;
2527
+ if (request.body.requiredLabels !== void 0) fields.requiredLabels = request.body.requiredLabels;
2528
+ const updated = await updateScheduleFields(db, request.params.scheduleId, fields);
2529
+ if (!updated) return reply.code(404).send({ error: "Schedule not found." });
2530
+ stopScheduleJob(state.scheduleJobs, request.params.scheduleId);
2531
+ if (updated.enabled) {
2532
+ startScheduleJob(updated, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2533
+ updated.nextRunAt = state.scheduleJobs.get(request.params.scheduleId)?.nextDate()?.toISO() ?? null;
2534
+ await updateScheduleFields(db, request.params.scheduleId, { nextRunAt: updated.nextRunAt });
2535
+ }
2536
+ return updated;
2537
+ }
2538
+ );
2539
+ app.delete(
2540
+ "/api/schedules/:scheduleId",
2541
+ {
2542
+ schema: {
2543
+ tags: ["schedules"],
2544
+ summary: "Delete a schedule",
2545
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2546
+ response: {
2547
+ 200: { type: "object", required: ["deleted"], properties: { deleted: { type: "boolean" } } },
2548
+ 404: errorResponseSchema,
2549
+ 401: errorResponseSchema
2550
+ }
2551
+ }
2552
+ },
2553
+ async (request, reply) => {
2554
+ stopScheduleJob(state.scheduleJobs, request.params.scheduleId);
2555
+ const deleted = await deleteScheduleRow(db, request.params.scheduleId);
2556
+ if (!deleted) return reply.code(404).send({ error: "Schedule not found." });
2557
+ return { deleted: true };
2558
+ }
2559
+ );
2560
+ app.post(
2561
+ "/api/schedules/:scheduleId/trigger",
2562
+ {
2563
+ schema: {
2564
+ tags: ["schedules"],
2565
+ summary: "Manually trigger a schedule",
2566
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2567
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2568
+ }
2569
+ },
2570
+ async (request, reply) => {
2571
+ const schedule = await getScheduleById(db, request.params.scheduleId);
2572
+ if (!schedule) return reply.code(404).send({ error: "Schedule not found." });
2573
+ const atAgents = schedule.targetMode === "queue" ? "queue" : schedule.targetMode === "broadcast" ? "all" : schedule.targetAgents;
2574
+ const result = dispatchLeaderCommand(
2575
+ { type: "command.dispatch", atAgents, prompt: schedule.prompt, workspace: schedule.workspace ?? void 0, timeoutSec: schedule.timeoutSec ?? void 0 },
2576
+ null,
2577
+ void 0,
2578
+ schedule.priority,
2579
+ schedule.requiredLabels
2580
+ );
2581
+ if (!result.ok) return reply.code(400).send({ error: result.message });
2582
+ await onScheduleFire(request.params.scheduleId);
2583
+ return await getScheduleById(db, request.params.scheduleId);
2584
+ }
2585
+ );
2207
2586
  app.get("/ws/agent", { websocket: true }, (socket, request) => {
2208
2587
  if (!isAuthorized(options.authToken, request.url, request.headers)) {
2209
2588
  socket.close(1008, "unauthorized");
@@ -2278,6 +2657,9 @@ async function createAiTeamsServer(options) {
2278
2657
  buildSnapshot,
2279
2658
  close: async () => {
2280
2659
  closing = true;
2660
+ for (const job of state.scheduleJobs.values()) {
2661
+ job.stop();
2662
+ }
2281
2663
  for (const timer of state.taskTimeouts.values()) {
2282
2664
  clearTimeout(timer);
2283
2665
  }
@@ -2321,7 +2703,7 @@ if (isCli) {
2321
2703
  getArgValue2 = getArgValue;
2322
2704
  const args = process.argv.slice(2);
2323
2705
  if (args.includes("--version") || args.includes("-v")) {
2324
- console.log("0.1.4");
2706
+ console.log("0.2.0");
2325
2707
  process.exit(0);
2326
2708
  }
2327
2709
  if (args.includes("--help") || args.includes("-h")) {
@@ -2334,6 +2716,7 @@ if (isCli) {
2334
2716
  --port <port> \u670D\u52A1\u7AEF\u53E3 (\u9ED8\u8BA4 3789)
2335
2717
  --host <host> \u7ED1\u5B9A\u5730\u5740 (\u9ED8\u8BA4 0.0.0.0)
2336
2718
  --data-dir <dir> \u6570\u636E\u76EE\u5F55
2719
+ --database-url <url> PostgreSQL \u8FDE\u63A5\u5B57\u7B26\u4E32 (\u8BBE\u7F6E\u540E\u4F7F\u7528 PostgreSQL \u800C\u975E SQLite)
2337
2720
  --db-path <path> \u6570\u636E\u5E93\u8DEF\u5F84
2338
2721
  --log-level <level> \u65E5\u5FD7\u7EA7\u522B trace/debug/info/warn/error (\u9ED8\u8BA4 info)
2339
2722
  --log-dir <dir> \u65E5\u5FD7\u6587\u4EF6\u76EE\u5F55 (\u4E0D\u8BBE\u5219\u4EC5\u8F93\u51FA\u5230 stdout)
@@ -2346,6 +2729,7 @@ if (isCli) {
2346
2729
  const cliPort = getArgValue("--port");
2347
2730
  const cliHost = getArgValue("--host");
2348
2731
  const cliDataDir = getArgValue("--data-dir");
2732
+ const cliDatabaseUrl = getArgValue("--database-url");
2349
2733
  const cliDbPath = getArgValue("--db-path");
2350
2734
  const cliLogLevel = getArgValue("--log-level");
2351
2735
  const cliLogDir = getArgValue("--log-dir");
@@ -2353,6 +2737,7 @@ if (isCli) {
2353
2737
  if (cliPort) process.env.AI_TEAMS_SERVER_PORT = cliPort;
2354
2738
  if (cliHost) process.env.HOST = cliHost;
2355
2739
  if (cliDataDir) process.env.DATA_DIR = cliDataDir;
2740
+ if (cliDatabaseUrl) process.env.DATABASE_URL = cliDatabaseUrl;
2356
2741
  if (cliDbPath) process.env.DB_PATH = cliDbPath;
2357
2742
  if (cliLogLevel) process.env.LOG_LEVEL = cliLogLevel;
2358
2743
  if (cliLogDir) process.env.LOG_DIR = cliLogDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csdwd/ai-teams-server",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "AI Teams central server — Fastify HTTP + WebSocket server for task dispatch, employee management, and web UI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "@fastify/swagger": "^9.7.0",
21
21
  "@fastify/swagger-ui": "^5.2.5",
22
22
  "@fastify/websocket": "^11.0.0",
23
+ "cron": "^4.4.0",
23
24
  "fastify": "^5.2.0",
24
25
  "pg": "^8.20.0",
25
26
  "ws": "^8.18.3"