@csdwd/ai-teams-server 0.1.5 → 0.3.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 +653 -43
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import os from "node:os";
5
- import path from "node:path";
6
- import fs from "node:fs";
5
+ import path2 from "node:path";
6
+ import fs2 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";
@@ -230,6 +231,171 @@ function parseEncryptionKey(hex) {
230
231
  return key;
231
232
  }
232
233
 
234
+ // ../../packages/shared/dist/daemon.js
235
+ import fs from "node:fs";
236
+ import path from "node:path";
237
+ import { spawn } from "node:child_process";
238
+ function readPidFile(pidFile) {
239
+ try {
240
+ const content = fs.readFileSync(pidFile, "utf-8").trim();
241
+ const pid = Number(content);
242
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ function writePidFile(pidFile, pid) {
248
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
249
+ fs.writeFileSync(pidFile, String(pid), "utf-8");
250
+ }
251
+ function removePidFile(pidFile) {
252
+ try {
253
+ fs.unlinkSync(pidFile);
254
+ } catch {
255
+ }
256
+ }
257
+ function isProcessRunning(pid) {
258
+ try {
259
+ process.kill(pid, 0);
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+ function spawnWorker(script, args, logDir) {
266
+ fs.mkdirSync(logDir, { recursive: true });
267
+ const logFile = path.join(logDir, "worker.log");
268
+ const logStream = fs.openSync(logFile, "a");
269
+ const child = spawn(process.execPath, [script, ...args], {
270
+ stdio: ["ignore", logStream, logStream],
271
+ env: {
272
+ ...process.env,
273
+ __AI_TEAMS_DAEMON_WORKER: "1",
274
+ __AI_TEAMS_DAEMON_WATCHDOG: void 0
275
+ }
276
+ });
277
+ fs.closeSync(logStream);
278
+ return child;
279
+ }
280
+ var MAX_RESTARTS_PER_MINUTE = 10;
281
+ var RESTART_DELAY_MS = 3e3;
282
+ async function runWatchdog(opts) {
283
+ const { name, pidFile, logDir, workerScript, workerArgs } = opts;
284
+ writePidFile(pidFile, process.pid);
285
+ let restartTimestamps = [];
286
+ function cleanupAndExit(code) {
287
+ removePidFile(pidFile);
288
+ process.exit(code);
289
+ }
290
+ let worker = null;
291
+ let shuttingDown = false;
292
+ function shutdown() {
293
+ if (shuttingDown)
294
+ return;
295
+ shuttingDown = true;
296
+ if (worker)
297
+ worker.kill("SIGTERM");
298
+ }
299
+ process.on("SIGTERM", shutdown);
300
+ process.on("SIGINT", shutdown);
301
+ while (!shuttingDown) {
302
+ await new Promise((resolve) => {
303
+ worker = spawnWorker(workerScript, workerArgs, logDir);
304
+ worker.on("exit", (code, signal) => {
305
+ worker = null;
306
+ if (shuttingDown) {
307
+ cleanupAndExit(0);
308
+ }
309
+ if (code === 0) {
310
+ console.log(`${name}: worker exited cleanly, daemon stopping.`);
311
+ cleanupAndExit(0);
312
+ }
313
+ const now = Date.now();
314
+ restartTimestamps = restartTimestamps.filter((t) => now - t < 6e4);
315
+ restartTimestamps.push(now);
316
+ if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) {
317
+ console.error(`${name}: exceeded ${MAX_RESTARTS_PER_MINUTE} restarts/minute, daemon stopping.`);
318
+ cleanupAndExit(1);
319
+ }
320
+ console.log(`${name}: worker crashed (code=${code}, signal=${signal}), restarting in ${RESTART_DELAY_MS}ms...`);
321
+ setTimeout(resolve, RESTART_DELAY_MS);
322
+ });
323
+ });
324
+ }
325
+ cleanupAndExit(0);
326
+ }
327
+ function getDaemonStatus(pidFile) {
328
+ const pid = readPidFile(pidFile);
329
+ if (pid === null) {
330
+ return { running: false, pid: null };
331
+ }
332
+ if (isProcessRunning(pid)) {
333
+ return { running: true, pid };
334
+ }
335
+ removePidFile(pidFile);
336
+ return { running: false, pid: null };
337
+ }
338
+ async function stopDaemon(pidFile) {
339
+ const pid = readPidFile(pidFile);
340
+ if (pid === null) {
341
+ console.log("Not running (no PID file found).");
342
+ return;
343
+ }
344
+ if (!isProcessRunning(pid)) {
345
+ removePidFile(pidFile);
346
+ console.log("Not running (stale PID file cleaned).");
347
+ return;
348
+ }
349
+ process.kill(pid, "SIGTERM");
350
+ for (let i = 0; i < 20; i++) {
351
+ await new Promise((resolve) => setTimeout(resolve, 500));
352
+ if (!isProcessRunning(pid)) {
353
+ removePidFile(pidFile);
354
+ console.log(`Stopped (PID ${pid}).`);
355
+ return;
356
+ }
357
+ }
358
+ process.kill(pid, "SIGKILL");
359
+ removePidFile(pidFile);
360
+ console.log(`Force killed (PID ${pid}).`);
361
+ }
362
+ function daemonize(opts) {
363
+ if (process.env.__AI_TEAMS_DAEMON_WORKER === "1") {
364
+ return opts.run();
365
+ }
366
+ if (process.env.__AI_TEAMS_DAEMON_WATCHDOG === "1") {
367
+ return runWatchdog({
368
+ name: opts.name,
369
+ pidFile: opts.pidFile,
370
+ logDir: path.dirname(opts.logFile),
371
+ workerScript: process.argv[1],
372
+ workerArgs: process.argv.slice(2)
373
+ });
374
+ }
375
+ fs.mkdirSync(path.dirname(opts.logFile), { recursive: true });
376
+ const child = spawn(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
377
+ detached: true,
378
+ stdio: ["ignore", "ignore", "ignore"],
379
+ env: {
380
+ ...process.env,
381
+ __AI_TEAMS_DAEMON_WATCHDOG: "1"
382
+ }
383
+ });
384
+ child.unref();
385
+ return new Promise((resolve) => {
386
+ setTimeout(() => {
387
+ const pid = readPidFile(opts.pidFile);
388
+ if (pid) {
389
+ console.log(`${opts.name} started (PID ${pid})`);
390
+ } else {
391
+ console.error(`${opts.name}: failed to start \u2014 PID file not found.`);
392
+ }
393
+ resolve();
394
+ process.exit(pid ? 0 : 1);
395
+ }, 500);
396
+ });
397
+ }
398
+
233
399
  // src/db.ts
234
400
  import { DatabaseSync } from "node:sqlite";
235
401
  import pg from "pg";
@@ -373,6 +539,25 @@ async function initDb(db) {
373
539
  webhook_url TEXT NOT NULL
374
540
  )
375
541
  `);
542
+ await db.run(`
543
+ CREATE TABLE IF NOT EXISTS schedules (
544
+ id TEXT PRIMARY KEY,
545
+ name TEXT NOT NULL,
546
+ cron_expr TEXT NOT NULL,
547
+ enabled INTEGER NOT NULL DEFAULT 1,
548
+ target_mode TEXT NOT NULL DEFAULT 'queue',
549
+ target_agents TEXT NOT NULL DEFAULT '[]',
550
+ prompt TEXT NOT NULL,
551
+ workspace TEXT,
552
+ timeout_sec INTEGER,
553
+ priority INTEGER NOT NULL DEFAULT 0,
554
+ required_labels TEXT DEFAULT '[]',
555
+ last_run_at TEXT,
556
+ next_run_at TEXT,
557
+ created_at TEXT NOT NULL,
558
+ updated_at TEXT NOT NULL
559
+ )
560
+ `);
376
561
  }
377
562
  async function hydrateState(db, state, defaultTimeoutSec, maxLogChunksPerTask) {
378
563
  const employeeRows = await db.all("SELECT payload_json FROM employees");
@@ -609,6 +794,90 @@ function taskToDbValues(task) {
609
794
  function toSnakeCase(str) {
610
795
  return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
611
796
  }
797
+ var SCHEDULE_COLUMNS = [
798
+ "id",
799
+ "name",
800
+ "cron_expr",
801
+ "enabled",
802
+ "target_mode",
803
+ "target_agents",
804
+ "prompt",
805
+ "workspace",
806
+ "timeout_sec",
807
+ "priority",
808
+ "required_labels",
809
+ "last_run_at",
810
+ "next_run_at",
811
+ "created_at",
812
+ "updated_at"
813
+ ];
814
+ var SCHEDULE_PLACEHOLDERS = SCHEDULE_COLUMNS.map((_, i) => `$${i + 1}`).join(", ");
815
+ var SCHEDULE_UPDATE_SET = SCHEDULE_COLUMNS.slice(1).map((col) => `${col} = excluded.${col}`).join(", ");
816
+ function dbRowToSchedule(row) {
817
+ return {
818
+ id: row.id,
819
+ name: row.name,
820
+ cronExpr: row.cron_expr,
821
+ enabled: row.enabled === 1,
822
+ targetMode: row.target_mode,
823
+ targetAgents: JSON.parse(row.target_agents || "[]"),
824
+ prompt: row.prompt,
825
+ workspace: row.workspace,
826
+ timeoutSec: row.timeout_sec,
827
+ priority: row.priority,
828
+ requiredLabels: row.required_labels ? JSON.parse(row.required_labels) : null,
829
+ lastRunAt: row.last_run_at,
830
+ nextRunAt: row.next_run_at,
831
+ createdAt: row.created_at,
832
+ updatedAt: row.updated_at
833
+ };
834
+ }
835
+ function scheduleToDbValues(s) {
836
+ return [
837
+ s.id,
838
+ s.name,
839
+ s.cronExpr,
840
+ s.enabled ? 1 : 0,
841
+ s.targetMode,
842
+ JSON.stringify(s.targetAgents),
843
+ s.prompt,
844
+ s.workspace,
845
+ s.timeoutSec,
846
+ s.priority,
847
+ s.requiredLabels ? JSON.stringify(s.requiredLabels) : null,
848
+ s.lastRunAt,
849
+ s.nextRunAt,
850
+ s.createdAt,
851
+ s.updatedAt
852
+ ];
853
+ }
854
+ async function upsertSchedule(db, schedule) {
855
+ await db.run(
856
+ `INSERT INTO schedules (${SCHEDULE_COLUMNS.join(", ")}) VALUES (${SCHEDULE_PLACEHOLDERS}) ON CONFLICT(id) DO UPDATE SET ${SCHEDULE_UPDATE_SET}`,
857
+ scheduleToDbValues(schedule)
858
+ );
859
+ }
860
+ async function getAllSchedules(db) {
861
+ const rows = await db.all("SELECT * FROM schedules ORDER BY created_at");
862
+ return rows.map(dbRowToSchedule);
863
+ }
864
+ async function getScheduleById(db, id) {
865
+ const row = await db.get("SELECT * FROM schedules WHERE id = $1", [id]);
866
+ return row ? dbRowToSchedule(row) : void 0;
867
+ }
868
+ async function deleteScheduleRow(db, id) {
869
+ const row = await db.get("SELECT id FROM schedules WHERE id = $1", [id]);
870
+ if (!row) return false;
871
+ await db.run("DELETE FROM schedules WHERE id = $1", [id]);
872
+ return true;
873
+ }
874
+ async function updateScheduleFields(db, id, fields) {
875
+ const existing = await getScheduleById(db, id);
876
+ if (!existing) return void 0;
877
+ const updated = { ...existing, ...fields, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
878
+ await upsertSchedule(db, updated);
879
+ return updated;
880
+ }
612
881
 
613
882
  // src/schemas.ts
614
883
  var errorResponseSchema = {
@@ -890,6 +1159,65 @@ function parseWebhookUrl(value) {
890
1159
  }
891
1160
  return url.toString();
892
1161
  }
1162
+ var createScheduleRequestSchema = {
1163
+ type: "object",
1164
+ required: ["name", "cron", "prompt"],
1165
+ properties: {
1166
+ name: { type: "string", minLength: 1 },
1167
+ cron: { type: "string", minLength: 9 },
1168
+ enabled: { type: "boolean", default: true },
1169
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"], default: "queue" },
1170
+ targetAgents: { type: "array", items: { type: "string" } },
1171
+ prompt: { type: "string", minLength: 1 },
1172
+ workspace: { type: "string" },
1173
+ timeoutSec: { type: "number", minimum: 1 },
1174
+ priority: { type: "integer", minimum: 0, maximum: 3, default: 0 },
1175
+ requiredLabels: { type: "array", items: { type: "string" } }
1176
+ }
1177
+ };
1178
+ var updateScheduleRequestSchema = {
1179
+ type: "object",
1180
+ properties: {
1181
+ name: { type: "string", minLength: 1 },
1182
+ cron: { type: "string", minLength: 9 },
1183
+ enabled: { type: "boolean" },
1184
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"] },
1185
+ targetAgents: { type: "array", items: { type: "string" } },
1186
+ prompt: { type: "string", minLength: 1 },
1187
+ workspace: { type: "string" },
1188
+ timeoutSec: { type: "number", minimum: 1 },
1189
+ priority: { type: "integer", minimum: 0, maximum: 3 },
1190
+ requiredLabels: { type: "array", items: { type: "string" } }
1191
+ }
1192
+ };
1193
+ var scheduleResponseSchema = {
1194
+ type: "object",
1195
+ required: ["id", "name", "cron", "enabled", "targetMode", "prompt", "createdAt", "updatedAt"],
1196
+ properties: {
1197
+ id: { type: "string" },
1198
+ name: { type: "string" },
1199
+ cron: { type: "string" },
1200
+ enabled: { type: "boolean" },
1201
+ targetMode: { type: "string", enum: ["queue", "direct", "broadcast"] },
1202
+ targetAgents: { type: "array", items: { type: "string" } },
1203
+ prompt: { type: "string" },
1204
+ workspace: { type: "string" },
1205
+ timeoutSec: { type: "number" },
1206
+ priority: { type: "integer" },
1207
+ requiredLabels: { type: "array", items: { type: "string" } },
1208
+ lastRunAt: { type: "string" },
1209
+ nextRunAt: { type: "string" },
1210
+ createdAt: { type: "string" },
1211
+ updatedAt: { type: "string" }
1212
+ }
1213
+ };
1214
+ var scheduleListResponseSchema = {
1215
+ type: "object",
1216
+ required: ["schedules"],
1217
+ properties: {
1218
+ schedules: { type: "array", items: scheduleResponseSchema }
1219
+ }
1220
+ };
893
1221
 
894
1222
  // src/dispatch.ts
895
1223
  import { createHmac, randomUUID } from "node:crypto";
@@ -1655,7 +1983,8 @@ function createInMemoryStateStore() {
1655
1983
  taskQueues: /* @__PURE__ */ new Map(),
1656
1984
  mainTaskQueues: /* @__PURE__ */ new Map(),
1657
1985
  sharedTaskQueue: [],
1658
- sharedQueueCursor: 0
1986
+ sharedQueueCursor: 0,
1987
+ scheduleJobs: /* @__PURE__ */ new Map()
1659
1988
  };
1660
1989
  }
1661
1990
 
@@ -1705,6 +2034,41 @@ function createEncryptor(encryptionKeyHex) {
1705
2034
  };
1706
2035
  }
1707
2036
 
2037
+ // src/scheduler.ts
2038
+ import { CronJob } from "cron";
2039
+ function startScheduleJob(schedule, dispatchFn, jobs, onFire) {
2040
+ const atAgents = schedule.targetMode === "queue" ? "queue" : schedule.targetMode === "broadcast" ? "all" : schedule.targetAgents;
2041
+ const job = new CronJob(
2042
+ schedule.cronExpr,
2043
+ () => {
2044
+ dispatchFn(
2045
+ {
2046
+ type: "command.dispatch",
2047
+ atAgents,
2048
+ prompt: schedule.prompt,
2049
+ workspace: schedule.workspace ?? void 0,
2050
+ timeoutSec: schedule.timeoutSec ?? void 0
2051
+ },
2052
+ null,
2053
+ void 0,
2054
+ schedule.priority,
2055
+ schedule.requiredLabels
2056
+ );
2057
+ onFire(schedule.id);
2058
+ },
2059
+ null,
2060
+ true
2061
+ );
2062
+ jobs.set(schedule.id, job);
2063
+ }
2064
+ function stopScheduleJob(jobs, scheduleId) {
2065
+ const job = jobs.get(scheduleId);
2066
+ if (job) {
2067
+ job.stop();
2068
+ jobs.delete(scheduleId);
2069
+ }
2070
+ }
2071
+
1708
2072
  // src/index.ts
1709
2073
  var DEFAULT_PORT = 3789;
1710
2074
  function isAuthorized(authToken, rawUrl, headers) {
@@ -1720,8 +2084,8 @@ async function createAiTeamsServer(options) {
1720
2084
  const defaultTimeoutSec = options.defaultTimeoutSec ?? 1800;
1721
2085
  const disconnectGraceMs = options.disconnectGraceMs ?? 15e3;
1722
2086
  const maxLogChunksPerTask = options.maxLogChunksPerTask ?? 400;
1723
- const dataDir = options.dataDir ?? path.join(process.cwd(), "data");
1724
- const dbPath = options.dbPath ?? path.join(dataDir, "ai-teams.db");
2087
+ const dataDir = options.dataDir ?? path2.join(process.cwd(), "data");
2088
+ const dbPath = options.dbPath ?? path2.join(dataDir, "ai-teams.db");
1725
2089
  let closing = false;
1726
2090
  const state = createInMemoryStateStore();
1727
2091
  const db = await createDatabaseFromEnv({
@@ -1736,13 +2100,13 @@ async function createAiTeamsServer(options) {
1736
2100
  const logDir = options.logDir || process.env.LOG_DIR;
1737
2101
  let loggerConfig = { level: logLevel };
1738
2102
  if (logDir && options.logger !== false) {
1739
- fs.mkdirSync(logDir, { recursive: true });
2103
+ fs2.mkdirSync(logDir, { recursive: true });
1740
2104
  loggerConfig = {
1741
2105
  level: logLevel,
1742
2106
  transport: {
1743
2107
  targets: [
1744
2108
  { target: "pino/file", options: { destination: 1 }, level: logLevel },
1745
- { target: "pino/file", options: { destination: path.join(logDir, "server.log") }, level: logLevel }
2109
+ { target: "pino/file", options: { destination: path2.join(logDir, "server.log") }, level: logLevel }
1746
2110
  ]
1747
2111
  }
1748
2112
  };
@@ -1775,8 +2139,8 @@ async function createAiTeamsServer(options) {
1775
2139
  },
1776
2140
  staticCSP: true
1777
2141
  });
1778
- const webDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "web");
1779
- if (fs.existsSync(webDir)) {
2142
+ const webDir = path2.join(path2.dirname(fileURLToPath(import.meta.url)), "web");
2143
+ if (fs2.existsSync(webDir)) {
1780
2144
  await app.register(fastifyStatic, { root: webDir, prefix: "/" });
1781
2145
  app.setNotFoundHandler((_, reply) => {
1782
2146
  reply.sendFile("index.html");
@@ -1794,6 +2158,29 @@ async function createAiTeamsServer(options) {
1794
2158
  encryptor: createEncryptor(process.env.AI_TEAMS_ENCRYPTION_KEY)
1795
2159
  };
1796
2160
  const { dispatchLeaderCommand, handleAgentMessage, handleLeaderMessage, cancelTaskById } = createDispatch(dispatchCtx);
2161
+ const scheduleDispatchFn = (message, webhookUrl, cliConfig, priority, requiredLabels) => {
2162
+ return dispatchLeaderCommand(message, webhookUrl, cliConfig, priority, requiredLabels);
2163
+ };
2164
+ async function onScheduleFire(scheduleId) {
2165
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2166
+ const job = state.scheduleJobs.get(scheduleId);
2167
+ const nextRun = job?.nextDate()?.toISO() ?? null;
2168
+ await updateScheduleFields(db, scheduleId, { lastRunAt: now, nextRunAt: nextRun });
2169
+ }
2170
+ async function loadAndStartSchedules() {
2171
+ const schedules = await getAllSchedules(db);
2172
+ for (const schedule of schedules) {
2173
+ if (schedule.enabled) {
2174
+ try {
2175
+ startScheduleJob(schedule, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2176
+ } catch (err) {
2177
+ app.log.error({ scheduleId: schedule.id, cronExpr: schedule.cronExpr, err }, "Failed to start schedule");
2178
+ }
2179
+ }
2180
+ }
2181
+ app.log.info({ count: schedules.filter((s) => s.enabled).length }, "Schedules loaded");
2182
+ }
2183
+ await loadAndStartSchedules();
1797
2184
  function buildSnapshot() {
1798
2185
  return {
1799
2186
  employees: [...state.employees.values()],
@@ -1864,19 +2251,19 @@ async function createAiTeamsServer(options) {
1864
2251
  return { employeeId, workspace: null, activeSessionId: null, sessions: [] };
1865
2252
  }
1866
2253
  const encodedPath = workspace.replace(/\//g, "-");
1867
- const claudeProjectsDir = path.join(os.homedir(), ".claude", "projects", encodedPath);
2254
+ const claudeProjectsDir = path2.join(os.homedir(), ".claude", "projects", encodedPath);
1868
2255
  let entries;
1869
2256
  try {
1870
- entries = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
2257
+ entries = fs2.readdirSync(claudeProjectsDir, { withFileTypes: true });
1871
2258
  } catch {
1872
2259
  return { employeeId, workspace, activeSessionId: null, sessions: [] };
1873
2260
  }
1874
2261
  const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).sort((a, b) => b.name.localeCompare(a.name));
1875
2262
  const sessions = jsonlFiles.map((entry) => {
1876
- const filePath = path.join(claudeProjectsDir, entry.name);
1877
- const stat = fs.statSync(filePath);
2263
+ const filePath = path2.join(claudeProjectsDir, entry.name);
2264
+ const stat = fs2.statSync(filePath);
1878
2265
  const sessionId = entry.name.replace(/\.jsonl$/, "");
1879
- const content = fs.readFileSync(filePath, "utf8");
2266
+ const content = fs2.readFileSync(filePath, "utf8");
1880
2267
  const lines = content.split("\n").filter(Boolean);
1881
2268
  const lineCount = lines.length;
1882
2269
  let firstUserMessage = null;
@@ -1889,9 +2276,9 @@ async function createAiTeamsServer(options) {
1889
2276
  return { id: sessionId, sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), lineCount, firstUserMessage, latestUserMessage };
1890
2277
  });
1891
2278
  let activeSessionId = null;
1892
- const agentStatePath = path.join(workspace, ".ai-teams", "agents", employeeId, "session-state.json");
2279
+ const agentStatePath = path2.join(workspace, ".ai-teams", "agents", employeeId, "session-state.json");
1893
2280
  try {
1894
- const raw = fs.readFileSync(agentStatePath, "utf8");
2281
+ const raw = fs2.readFileSync(agentStatePath, "utf8");
1895
2282
  activeSessionId = JSON.parse(raw).claudeSessionId ?? null;
1896
2283
  } catch {
1897
2284
  }
@@ -1902,7 +2289,7 @@ async function createAiTeamsServer(options) {
1902
2289
  if (request.url.startsWith("/ws/") || request.url.startsWith("/docs")) {
1903
2290
  return;
1904
2291
  }
1905
- const ext = path.extname(request.url.split("?")[0]);
2292
+ const ext = path2.extname(request.url.split("?")[0]);
1906
2293
  if (request.url === "/" || staticExts.has(ext)) {
1907
2294
  return;
1908
2295
  }
@@ -2204,6 +2591,163 @@ async function createAiTeamsServer(options) {
2204
2591
  return { deleted: true };
2205
2592
  }
2206
2593
  );
2594
+ app.get(
2595
+ "/api/schedules",
2596
+ {
2597
+ schema: {
2598
+ tags: ["schedules"],
2599
+ summary: "List all schedules",
2600
+ response: { 200: scheduleListResponseSchema, 401: errorResponseSchema }
2601
+ }
2602
+ },
2603
+ async () => {
2604
+ const schedules = await getAllSchedules(db);
2605
+ return { schedules };
2606
+ }
2607
+ );
2608
+ app.get(
2609
+ "/api/schedules/:scheduleId",
2610
+ {
2611
+ schema: {
2612
+ tags: ["schedules"],
2613
+ summary: "Get a schedule by ID",
2614
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2615
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2616
+ }
2617
+ },
2618
+ async (request, reply) => {
2619
+ const schedule = await getScheduleById(db, request.params.scheduleId);
2620
+ if (!schedule) return reply.code(404).send({ error: "Schedule not found." });
2621
+ return schedule;
2622
+ }
2623
+ );
2624
+ app.post(
2625
+ "/api/schedules",
2626
+ {
2627
+ schema: {
2628
+ tags: ["schedules"],
2629
+ summary: "Create a schedule",
2630
+ body: createScheduleRequestSchema,
2631
+ response: { 201: scheduleResponseSchema, 400: errorResponseSchema, 401: errorResponseSchema }
2632
+ }
2633
+ },
2634
+ async (request, reply) => {
2635
+ const body = request.body;
2636
+ const id = randomUUID2();
2637
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2638
+ const schedule = {
2639
+ id,
2640
+ name: body.name,
2641
+ cronExpr: body.cron,
2642
+ enabled: body.enabled ?? true,
2643
+ targetMode: body.targetMode ?? "queue",
2644
+ targetAgents: body.targetAgents ?? [],
2645
+ prompt: body.prompt,
2646
+ workspace: body.workspace ?? null,
2647
+ timeoutSec: body.timeoutSec ?? null,
2648
+ priority: body.priority ?? 0,
2649
+ requiredLabels: body.requiredLabels ?? null,
2650
+ lastRunAt: null,
2651
+ nextRunAt: null,
2652
+ createdAt: now,
2653
+ updatedAt: now
2654
+ };
2655
+ try {
2656
+ if (schedule.enabled) {
2657
+ startScheduleJob(schedule, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2658
+ schedule.nextRunAt = state.scheduleJobs.get(id)?.nextDate()?.toISO() ?? null;
2659
+ }
2660
+ await upsertSchedule(db, schedule);
2661
+ return reply.code(201).send(schedule);
2662
+ } catch (err) {
2663
+ stopScheduleJob(state.scheduleJobs, id);
2664
+ return reply.code(400).send({ error: err instanceof Error ? err.message : "Invalid schedule." });
2665
+ }
2666
+ }
2667
+ );
2668
+ app.patch(
2669
+ "/api/schedules/:scheduleId",
2670
+ {
2671
+ schema: {
2672
+ tags: ["schedules"],
2673
+ summary: "Update a schedule",
2674
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2675
+ body: updateScheduleRequestSchema,
2676
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2677
+ }
2678
+ },
2679
+ async (request, reply) => {
2680
+ const existing = await getScheduleById(db, request.params.scheduleId);
2681
+ if (!existing) return reply.code(404).send({ error: "Schedule not found." });
2682
+ const fields = {};
2683
+ if (request.body.name !== void 0) fields.name = request.body.name;
2684
+ if (request.body.cron !== void 0) fields.cronExpr = request.body.cron;
2685
+ if (request.body.enabled !== void 0) fields.enabled = request.body.enabled;
2686
+ if (request.body.targetMode !== void 0) fields.targetMode = request.body.targetMode;
2687
+ if (request.body.targetAgents !== void 0) fields.targetAgents = request.body.targetAgents;
2688
+ if (request.body.prompt !== void 0) fields.prompt = request.body.prompt;
2689
+ if (request.body.workspace !== void 0) fields.workspace = request.body.workspace;
2690
+ if (request.body.timeoutSec !== void 0) fields.timeoutSec = request.body.timeoutSec;
2691
+ if (request.body.priority !== void 0) fields.priority = request.body.priority;
2692
+ if (request.body.requiredLabels !== void 0) fields.requiredLabels = request.body.requiredLabels;
2693
+ const updated = await updateScheduleFields(db, request.params.scheduleId, fields);
2694
+ if (!updated) return reply.code(404).send({ error: "Schedule not found." });
2695
+ stopScheduleJob(state.scheduleJobs, request.params.scheduleId);
2696
+ if (updated.enabled) {
2697
+ startScheduleJob(updated, scheduleDispatchFn, state.scheduleJobs, onScheduleFire);
2698
+ updated.nextRunAt = state.scheduleJobs.get(request.params.scheduleId)?.nextDate()?.toISO() ?? null;
2699
+ await updateScheduleFields(db, request.params.scheduleId, { nextRunAt: updated.nextRunAt });
2700
+ }
2701
+ return updated;
2702
+ }
2703
+ );
2704
+ app.delete(
2705
+ "/api/schedules/:scheduleId",
2706
+ {
2707
+ schema: {
2708
+ tags: ["schedules"],
2709
+ summary: "Delete a schedule",
2710
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2711
+ response: {
2712
+ 200: { type: "object", required: ["deleted"], properties: { deleted: { type: "boolean" } } },
2713
+ 404: errorResponseSchema,
2714
+ 401: errorResponseSchema
2715
+ }
2716
+ }
2717
+ },
2718
+ async (request, reply) => {
2719
+ stopScheduleJob(state.scheduleJobs, request.params.scheduleId);
2720
+ const deleted = await deleteScheduleRow(db, request.params.scheduleId);
2721
+ if (!deleted) return reply.code(404).send({ error: "Schedule not found." });
2722
+ return { deleted: true };
2723
+ }
2724
+ );
2725
+ app.post(
2726
+ "/api/schedules/:scheduleId/trigger",
2727
+ {
2728
+ schema: {
2729
+ tags: ["schedules"],
2730
+ summary: "Manually trigger a schedule",
2731
+ params: { type: "object", required: ["scheduleId"], properties: { scheduleId: { type: "string", minLength: 1 } } },
2732
+ response: { 200: scheduleResponseSchema, 404: errorResponseSchema, 401: errorResponseSchema }
2733
+ }
2734
+ },
2735
+ async (request, reply) => {
2736
+ const schedule = await getScheduleById(db, request.params.scheduleId);
2737
+ if (!schedule) return reply.code(404).send({ error: "Schedule not found." });
2738
+ const atAgents = schedule.targetMode === "queue" ? "queue" : schedule.targetMode === "broadcast" ? "all" : schedule.targetAgents;
2739
+ const result = dispatchLeaderCommand(
2740
+ { type: "command.dispatch", atAgents, prompt: schedule.prompt, workspace: schedule.workspace ?? void 0, timeoutSec: schedule.timeoutSec ?? void 0 },
2741
+ null,
2742
+ void 0,
2743
+ schedule.priority,
2744
+ schedule.requiredLabels
2745
+ );
2746
+ if (!result.ok) return reply.code(400).send({ error: result.message });
2747
+ await onScheduleFire(request.params.scheduleId);
2748
+ return await getScheduleById(db, request.params.scheduleId);
2749
+ }
2750
+ );
2207
2751
  app.get("/ws/agent", { websocket: true }, (socket, request) => {
2208
2752
  if (!isAuthorized(options.authToken, request.url, request.headers)) {
2209
2753
  socket.close(1008, "unauthorized");
@@ -2278,6 +2822,9 @@ async function createAiTeamsServer(options) {
2278
2822
  buildSnapshot,
2279
2823
  close: async () => {
2280
2824
  closing = true;
2825
+ for (const job of state.scheduleJobs.values()) {
2826
+ job.stop();
2827
+ }
2281
2828
  for (const timer of state.taskTimeouts.values()) {
2282
2829
  clearTimeout(timer);
2283
2830
  }
@@ -2311,62 +2858,125 @@ async function startServer(options = readOptionsFromEnv()) {
2311
2858
  await server.app.listen({ port: options.port ?? DEFAULT_PORT, host: options.host ?? "0.0.0.0" });
2312
2859
  return server;
2313
2860
  }
2314
- var isCli = process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
2861
+ var isCli = process.argv[1] && fs2.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
2315
2862
  if (isCli) {
2316
2863
  let getArgValue = function(name) {
2317
2864
  const idx = args.indexOf(name);
2318
2865
  if (idx === -1) return void 0;
2319
2866
  return args[idx + 1];
2867
+ }, resolveDataDir = function() {
2868
+ return getArgValue("--data-dir") || process.env.DATA_DIR || path2.join(process.cwd(), "data");
2869
+ }, resolvePidFile = function() {
2870
+ return path2.join(resolveDataDir(), ".ai-teams-server.pid");
2871
+ }, resolveLogDir = function() {
2872
+ return getArgValue("--log-dir") || process.env.LOG_DIR || path2.join(resolveDataDir(), "logs");
2873
+ }, applyCliArgsToEnv = function() {
2874
+ const cliToken = getArgValue("--token");
2875
+ const cliPort = getArgValue("--port");
2876
+ const cliHost = getArgValue("--host");
2877
+ const cliDataDir = getArgValue("--data-dir");
2878
+ const cliDatabaseUrl = getArgValue("--database-url");
2879
+ const cliDbPath = getArgValue("--db-path");
2880
+ const cliLogLevel = getArgValue("--log-level");
2881
+ const cliLogDir = getArgValue("--log-dir");
2882
+ if (cliToken) process.env.AI_TEAMS_AUTH_TOKEN = cliToken;
2883
+ if (cliPort) process.env.AI_TEAMS_SERVER_PORT = cliPort;
2884
+ if (cliHost) process.env.HOST = cliHost;
2885
+ if (cliDataDir) process.env.DATA_DIR = cliDataDir;
2886
+ if (cliDatabaseUrl) process.env.DATABASE_URL = cliDatabaseUrl;
2887
+ if (cliDbPath) process.env.DB_PATH = cliDbPath;
2888
+ if (cliLogLevel) process.env.LOG_LEVEL = cliLogLevel;
2889
+ if (cliLogDir) process.env.LOG_DIR = cliLogDir;
2320
2890
  };
2321
- getArgValue2 = getArgValue;
2891
+ getArgValue2 = getArgValue, resolveDataDir2 = resolveDataDir, resolvePidFile2 = resolvePidFile, resolveLogDir2 = resolveLogDir, applyCliArgsToEnv2 = applyCliArgsToEnv;
2322
2892
  const args = process.argv.slice(2);
2323
2893
  if (args.includes("--version") || args.includes("-v")) {
2324
- console.log("0.1.5");
2894
+ console.log("0.3.0");
2325
2895
  process.exit(0);
2326
2896
  }
2327
2897
  if (args.includes("--help") || args.includes("-h")) {
2328
2898
  console.log(`ai-teams-server \u2014 AI Teams \u4E2D\u592E\u670D\u52A1\u5668
2329
2899
 
2330
- \u7528\u6CD5: ai-teams-server [\u9009\u9879]
2900
+ \u7528\u6CD5: ai-teams-server <command> [\u9009\u9879]
2901
+
2902
+ \u547D\u4EE4:
2903
+ start [\u9009\u9879] \u540E\u53F0\u542F\u52A8\u5B88\u62A4\u8FDB\u7A0B
2904
+ stop \u505C\u6B62\u5B88\u62A4\u8FDB\u7A0B
2905
+ restart [\u9009\u9879] \u91CD\u542F\u5B88\u62A4\u8FDB\u7A0B
2906
+ status \u67E5\u770B\u8FD0\u884C\u72B6\u6001
2331
2907
 
2332
2908
  \u9009\u9879:
2333
2909
  --token <token> \u8BA4\u8BC1 Token (\u5FC5\u586B\uFF0C\u6216\u8BBE AI_TEAMS_AUTH_TOKEN)
2334
2910
  --port <port> \u670D\u52A1\u7AEF\u53E3 (\u9ED8\u8BA4 3789)
2335
2911
  --host <host> \u7ED1\u5B9A\u5730\u5740 (\u9ED8\u8BA4 0.0.0.0)
2336
2912
  --data-dir <dir> \u6570\u636E\u76EE\u5F55
2913
+ --database-url <url> PostgreSQL \u8FDE\u63A5\u5B57\u7B26\u4E32 (\u8BBE\u7F6E\u540E\u4F7F\u7528 PostgreSQL \u800C\u975E SQLite)
2337
2914
  --db-path <path> \u6570\u636E\u5E93\u8DEF\u5F84
2338
2915
  --log-level <level> \u65E5\u5FD7\u7EA7\u522B trace/debug/info/warn/error (\u9ED8\u8BA4 info)
2339
2916
  --log-dir <dir> \u65E5\u5FD7\u6587\u4EF6\u76EE\u5F55 (\u4E0D\u8BBE\u5219\u4EC5\u8F93\u51FA\u5230 stdout)
2340
2917
  -v, --version \u663E\u793A\u7248\u672C\u53F7
2341
2918
  -h, --help \u663E\u793A\u5E2E\u52A9
2919
+
2920
+ \u4E0D\u5E26\u547D\u4EE4\u76F4\u63A5\u8FD0\u884C\u65F6\u4E3A\u524D\u53F0\u6A21\u5F0F\u3002
2342
2921
  `);
2343
2922
  process.exit(0);
2344
2923
  }
2345
- const cliToken = getArgValue("--token");
2346
- const cliPort = getArgValue("--port");
2347
- const cliHost = getArgValue("--host");
2348
- const cliDataDir = getArgValue("--data-dir");
2349
- const cliDbPath = getArgValue("--db-path");
2350
- const cliLogLevel = getArgValue("--log-level");
2351
- const cliLogDir = getArgValue("--log-dir");
2352
- if (cliToken) process.env.AI_TEAMS_AUTH_TOKEN = cliToken;
2353
- if (cliPort) process.env.AI_TEAMS_SERVER_PORT = cliPort;
2354
- if (cliHost) process.env.HOST = cliHost;
2355
- if (cliDataDir) process.env.DATA_DIR = cliDataDir;
2356
- if (cliDbPath) process.env.DB_PATH = cliDbPath;
2357
- if (cliLogLevel) process.env.LOG_LEVEL = cliLogLevel;
2358
- if (cliLogDir) process.env.LOG_DIR = cliLogDir;
2359
- const options = readOptionsFromEnv();
2360
- if (!options.authToken) {
2361
- console.error("\u9519\u8BEF: \u9700\u8981\u8BA4\u8BC1 Token\u3002\u4F7F\u7528 --token <token> \u6216\u8BBE\u7F6E AI_TEAMS_AUTH_TOKEN \u73AF\u5883\u53D8\u91CF\u3002");
2362
- process.exit(1);
2924
+ const subcommand = args[0];
2925
+ if (subcommand === "start" || subcommand === "restart") {
2926
+ void (async () => {
2927
+ if (subcommand === "restart") {
2928
+ const status = getDaemonStatus(resolvePidFile());
2929
+ if (status.running) {
2930
+ await stopDaemon(resolvePidFile());
2931
+ }
2932
+ }
2933
+ applyCliArgsToEnv();
2934
+ if (!process.env.AI_TEAMS_AUTH_TOKEN) {
2935
+ console.error("\u9519\u8BEF: \u9700\u8981\u8BA4\u8BC1 Token\u3002\u4F7F\u7528 --token <token> \u6216\u8BBE\u7F6E AI_TEAMS_AUTH_TOKEN \u73AF\u5883\u53D8\u91CF\u3002");
2936
+ process.exit(1);
2937
+ }
2938
+ if (!process.env.LOG_DIR) {
2939
+ process.env.LOG_DIR = resolveLogDir();
2940
+ }
2941
+ await daemonize({
2942
+ name: "ai-teams-server",
2943
+ pidFile: resolvePidFile(),
2944
+ logFile: path2.join(resolveLogDir(), "server.log"),
2945
+ run: async () => {
2946
+ await startServer(readOptionsFromEnv());
2947
+ }
2948
+ });
2949
+ })();
2950
+ } else if (subcommand === "stop") {
2951
+ void (async () => {
2952
+ await stopDaemon(resolvePidFile());
2953
+ })();
2954
+ } else if (subcommand === "status") {
2955
+ const status = getDaemonStatus(resolvePidFile());
2956
+ if (status.running) {
2957
+ console.log(`ai-teams-server is running (PID ${status.pid})`);
2958
+ console.log(`Log: ${path2.join(resolveLogDir(), "server.log")}`);
2959
+ } else {
2960
+ console.log("ai-teams-server is not running.");
2961
+ }
2962
+ } else {
2963
+ applyCliArgsToEnv();
2964
+ const options = readOptionsFromEnv();
2965
+ if (!options.authToken) {
2966
+ console.error("\u9519\u8BEF: \u9700\u8981\u8BA4\u8BC1 Token\u3002\u4F7F\u7528 --token <token> \u6216\u8BBE\u7F6E AI_TEAMS_AUTH_TOKEN \u73AF\u5883\u53D8\u91CF\u3002");
2967
+ process.exit(1);
2968
+ }
2969
+ startServer(options).catch((error) => {
2970
+ console.error(error);
2971
+ process.exit(1);
2972
+ });
2363
2973
  }
2364
- startServer(options).catch((error) => {
2365
- console.error(error);
2366
- process.exit(1);
2367
- });
2368
2974
  }
2369
2975
  var getArgValue2;
2976
+ var resolveDataDir2;
2977
+ var resolvePidFile2;
2978
+ var resolveLogDir2;
2979
+ var applyCliArgsToEnv2;
2370
2980
  export {
2371
2981
  createAiTeamsServer,
2372
2982
  readOptionsFromEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csdwd/ai-teams-server",
3
- "version": "0.1.5",
3
+ "version": "0.3.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"