cascade-ai 0.16.0 → 0.17.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/index.cjs CHANGED
@@ -236,7 +236,7 @@ var init_audit_logger = __esm({
236
236
  });
237
237
 
238
238
  // src/constants.ts
239
- var CASCADE_VERSION = "0.16.0";
239
+ var CASCADE_VERSION = "0.17.0";
240
240
  var CASCADE_CONFIG_DIR = ".cascade";
241
241
  var CASCADE_MD_FILE = "CASCADE.md";
242
242
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -11945,6 +11945,25 @@ var DashboardSocket = class {
11945
11945
  });
11946
11946
  });
11947
11947
  }
11948
+ /**
11949
+ * Boardroom plan decisions from a connected client. The desktop shows a
11950
+ * plan-review modal on `plan:approval-required` and answers here; the
11951
+ * server routes the decision into the paused run via resolvePlanApproval.
11952
+ */
11953
+ onPlanDecision(callback) {
11954
+ this.io.on("connection", (socket) => {
11955
+ socket.on("plan:decision", (payload) => {
11956
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
11957
+ callback({
11958
+ sessionId: payload.sessionId,
11959
+ approved: payload.approved,
11960
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
11961
+ editedPlan: payload.editedPlan
11962
+ });
11963
+ }
11964
+ });
11965
+ });
11966
+ }
11948
11967
  onSessionSteer(callback) {
11949
11968
  this.io.on("connection", (socket) => {
11950
11969
  socket.on("session:steer", (payload) => {
@@ -11986,6 +12005,122 @@ var DashboardSocket = class {
11986
12005
  this.io.close();
11987
12006
  }
11988
12007
  };
12008
+ var TaskScheduler = class {
12009
+ cronJobs = /* @__PURE__ */ new Map();
12010
+ store;
12011
+ runner;
12012
+ constructor(store, runner) {
12013
+ this.store = store;
12014
+ this.runner = runner;
12015
+ }
12016
+ start() {
12017
+ const tasks = this.store.listScheduledTasks();
12018
+ for (const task of tasks) {
12019
+ if (task.enabled) this.schedule(task);
12020
+ }
12021
+ }
12022
+ stop() {
12023
+ for (const job of this.cronJobs.values()) job.stop();
12024
+ this.cronJobs.clear();
12025
+ }
12026
+ schedule(task) {
12027
+ if (!cron__default.default.validate(task.cronExpression)) {
12028
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12029
+ }
12030
+ const existing = this.cronJobs.get(task.id);
12031
+ if (existing) {
12032
+ try {
12033
+ existing.stop();
12034
+ } catch {
12035
+ }
12036
+ }
12037
+ const job = cron__default.default.schedule(task.cronExpression, async () => {
12038
+ try {
12039
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12040
+ this.store.saveScheduledTask(task);
12041
+ await this.runner(task);
12042
+ } catch (err) {
12043
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12044
+ }
12045
+ }, { timezone: "UTC" });
12046
+ this.cronJobs.set(task.id, job);
12047
+ }
12048
+ unschedule(taskId) {
12049
+ this.cronJobs.get(taskId)?.stop();
12050
+ this.cronJobs.delete(taskId);
12051
+ }
12052
+ add(task) {
12053
+ this.store.saveScheduledTask(task);
12054
+ if (task.enabled) this.schedule(task);
12055
+ }
12056
+ remove(taskId) {
12057
+ this.unschedule(taskId);
12058
+ this.store.deleteScheduledTask(taskId);
12059
+ }
12060
+ list() {
12061
+ return this.store.listScheduledTasks();
12062
+ }
12063
+ isRunning(taskId) {
12064
+ return this.cronJobs.has(taskId);
12065
+ }
12066
+ static validateCron(expression) {
12067
+ return cron__default.default.validate(expression);
12068
+ }
12069
+ };
12070
+
12071
+ // src/dashboard/cost-stats.ts
12072
+ var DAY_MS = 24 * 60 * 60 * 1e3;
12073
+ function utcDateKey(d) {
12074
+ return d.toISOString().slice(0, 10);
12075
+ }
12076
+ function aggregateCostStats(sessions, opts = {}) {
12077
+ const days = Math.max(1, opts.days ?? 30);
12078
+ const topN = Math.max(1, opts.topN ?? 8);
12079
+ const now = opts.now ?? /* @__PURE__ */ new Date();
12080
+ const buckets = /* @__PURE__ */ new Map();
12081
+ for (let i = days - 1; i >= 0; i--) {
12082
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
12083
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
12084
+ }
12085
+ let totalCostUsd = 0;
12086
+ let totalTokens = 0;
12087
+ let totalRuns = 0;
12088
+ for (const s of sessions) {
12089
+ const cost = s.metadata?.totalCostUsd ?? 0;
12090
+ const tokens = s.metadata?.totalTokens ?? 0;
12091
+ const runs = s.metadata?.taskCount ?? 0;
12092
+ totalCostUsd += cost;
12093
+ totalTokens += tokens;
12094
+ totalRuns += runs;
12095
+ const when = new Date(s.updatedAt || s.createdAt);
12096
+ if (!Number.isNaN(when.getTime())) {
12097
+ const bucket = buckets.get(utcDateKey(when));
12098
+ if (bucket) {
12099
+ bucket.costUsd += cost;
12100
+ bucket.tokens += tokens;
12101
+ bucket.runs += runs;
12102
+ }
12103
+ }
12104
+ }
12105
+ const topSessions = [...sessions].filter((s) => (s.metadata?.totalCostUsd ?? 0) > 0).sort((a, b) => (b.metadata?.totalCostUsd ?? 0) - (a.metadata?.totalCostUsd ?? 0)).slice(0, topN).map((s) => ({
12106
+ sessionId: s.id,
12107
+ title: s.title,
12108
+ costUsd: s.metadata?.totalCostUsd ?? 0,
12109
+ tokens: s.metadata?.totalTokens ?? 0,
12110
+ runs: s.metadata?.taskCount ?? 0,
12111
+ updatedAt: s.updatedAt
12112
+ }));
12113
+ return {
12114
+ totalCostUsd,
12115
+ totalTokens,
12116
+ totalSessions: sessions.length,
12117
+ totalRuns,
12118
+ perDay: [...buckets.values()],
12119
+ topSessions
12120
+ };
12121
+ }
12122
+
12123
+ // src/dashboard/server.ts
11989
12124
  var __dirname$1 = path20__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
11990
12125
  var DashboardServer = class {
11991
12126
  app;
@@ -12011,6 +12146,14 @@ var DashboardServer = class {
12011
12146
  * map is how that answer reaches the run that's blocked on it.
12012
12147
  */
12013
12148
  pendingApprovals = /* @__PURE__ */ new Map();
12149
+ /**
12150
+ * The orchestration decision trail ("why") of each session's most recent
12151
+ * run — captured when the run ends so the desktop's Why panel can show it
12152
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
12153
+ */
12154
+ whyBySession = /* @__PURE__ */ new Map();
12155
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
12156
+ scheduler;
12014
12157
  port;
12015
12158
  host;
12016
12159
  workspacePath;
@@ -12028,6 +12171,7 @@ var DashboardServer = class {
12028
12171
  secret: this.dashboardSecret,
12029
12172
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
12030
12173
  });
12174
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
12031
12175
  this.setupMiddleware();
12032
12176
  this.setupRoutes();
12033
12177
  this.socket.onSessionRate((sessionId, rating) => {
@@ -12091,6 +12235,9 @@ var DashboardServer = class {
12091
12235
  cascade.on("peer:message", (e) => {
12092
12236
  this.socket.emitPeerMessage(e);
12093
12237
  });
12238
+ cascade.on("plan:approval-required", (e) => {
12239
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
12240
+ });
12094
12241
  try {
12095
12242
  const result = await cascade.run({
12096
12243
  prompt: runPrompt,
@@ -12098,7 +12245,8 @@ var DashboardServer = class {
12098
12245
  approvalCallback: this.makeApprovalCallback(sessionId)
12099
12246
  });
12100
12247
  this.recordSessionTask(sessionId, result.taskId);
12101
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
12248
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12249
+ this.captureWhy(sessionId, cascade, result);
12102
12250
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
12103
12251
  this.socket.broadcast("cost:update", {
12104
12252
  sessionId,
@@ -12108,6 +12256,7 @@ var DashboardServer = class {
12108
12256
  this.throttledBroadcast("workspace");
12109
12257
  } catch (err) {
12110
12258
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12259
+ this.captureWhy(sessionId, cascade);
12111
12260
  this.socket.emitToSocket(socketId, "session:error", {
12112
12261
  sessionId,
12113
12262
  error: err instanceof Error ? err.message : String(err)
@@ -12118,9 +12267,17 @@ var DashboardServer = class {
12118
12267
  this.denyPendingApprovals(sessionId);
12119
12268
  }
12120
12269
  });
12270
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
12271
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
12272
+ approved,
12273
+ note,
12274
+ editedPlan
12275
+ );
12276
+ });
12121
12277
  this.socket.onSessionHalt((sessionId) => {
12122
12278
  this.activeControllers.get(sessionId)?.abort();
12123
12279
  this.denyPendingApprovals(sessionId);
12280
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
12124
12281
  });
12125
12282
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
12126
12283
  const pending = this.pendingApprovals.get(requestId);
@@ -12153,12 +12310,21 @@ var DashboardServer = class {
12153
12310
  resolve();
12154
12311
  });
12155
12312
  });
12313
+ try {
12314
+ this.scheduler.start();
12315
+ } catch (err) {
12316
+ console.warn("[dashboard] failed to start task scheduler:", err);
12317
+ }
12156
12318
  }
12157
12319
  async stop() {
12158
12320
  if (this.broadcastTimer) {
12159
12321
  clearTimeout(this.broadcastTimer);
12160
12322
  this.broadcastTimer = null;
12161
12323
  }
12324
+ try {
12325
+ this.scheduler.stop();
12326
+ } catch {
12327
+ }
12162
12328
  this.socket.close();
12163
12329
  try {
12164
12330
  this.globalStore?.close();
@@ -12279,16 +12445,60 @@ var DashboardServer = class {
12279
12445
  return title;
12280
12446
  }
12281
12447
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
12282
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
12448
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
12283
12449
  try {
12284
12450
  if (reply && reply.trim()) {
12285
12451
  this.store.addMessage({ id: crypto3.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
12286
12452
  }
12453
+ if (result) {
12454
+ const session = this.store.getSession(sessionId);
12455
+ if (session) {
12456
+ this.store.updateSession(sessionId, {
12457
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12458
+ metadata: {
12459
+ ...session.metadata,
12460
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
12461
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
12462
+ taskCount: session.metadata.taskCount + 1
12463
+ }
12464
+ });
12465
+ }
12466
+ }
12287
12467
  } catch (err) {
12288
12468
  console.warn("[dashboard] failed to persist run end:", err);
12289
12469
  }
12290
12470
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
12291
12471
  }
12472
+ /**
12473
+ * Capture the run's decision trail + router economics ("why") and broadcast
12474
+ * it so the desktop's Why panel updates live; kept per-session for the
12475
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
12476
+ */
12477
+ captureWhy(sessionId, cascade, result) {
12478
+ try {
12479
+ const stats = cascade.getRouter().getStats();
12480
+ const savings = cascade.getRouter().getDelegationSavings();
12481
+ const report = {
12482
+ sessionId,
12483
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
12484
+ decisions: cascade.getDecisionLog(),
12485
+ savedUsd: savings.savedUsd,
12486
+ savedPct: savings.savedPct,
12487
+ totalCostUsd: stats.totalCostUsd,
12488
+ totalTokens: stats.totalTokens,
12489
+ costByTier: stats.costByTier,
12490
+ durationMs: result?.durationMs
12491
+ };
12492
+ this.whyBySession.set(sessionId, report);
12493
+ if (this.whyBySession.size > 50) {
12494
+ const oldest = this.whyBySession.keys().next().value;
12495
+ if (oldest) this.whyBySession.delete(oldest);
12496
+ }
12497
+ this.socket.broadcast("run:why", report);
12498
+ } catch (err) {
12499
+ console.warn("[dashboard] failed to capture decision trail:", err);
12500
+ }
12501
+ }
12292
12502
  /**
12293
12503
  * Route steering text into running Cascade instances. Targets the given
12294
12504
  * session, or every active session when none is specified (the desktop
@@ -12318,6 +12528,52 @@ var DashboardServer = class {
12318
12528
  this.pendingApprovals.set(request.id, { resolve, sessionId });
12319
12529
  });
12320
12530
  }
12531
+ /**
12532
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
12533
+ * tool approvals are auto-granted since nobody may be watching when the cron
12534
+ * fires — the Schedules UI states this. Events broadcast to every connected
12535
+ * client so an open desktop sees the run appear live.
12536
+ */
12537
+ async runScheduledTask(task) {
12538
+ const sessionId = crypto3.randomUUID();
12539
+ const prompt = task.prompt;
12540
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
12541
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
12542
+ this.activeSessions.set(sessionId, cascade);
12543
+ cascade.on("tier:status", (e) => {
12544
+ this.socket.broadcast("tier:status", { sessionId, ...e });
12545
+ });
12546
+ cascade.on("peer:message", (e) => {
12547
+ this.socket.emitPeerMessage(e);
12548
+ });
12549
+ try {
12550
+ const result = await cascade.run({
12551
+ prompt,
12552
+ identityId: task.identityId,
12553
+ approvalCallback: async () => ({ approved: true, always: false })
12554
+ });
12555
+ this.recordSessionTask(sessionId, result.taskId);
12556
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12557
+ this.captureWhy(sessionId, cascade, result);
12558
+ this.socket.broadcast("session:complete", { sessionId, result });
12559
+ this.socket.broadcast("cost:update", {
12560
+ sessionId,
12561
+ totalTokens: result.usage.totalTokens,
12562
+ totalCostUsd: result.usage.estimatedCostUsd
12563
+ });
12564
+ this.throttledBroadcast("workspace");
12565
+ } catch (err) {
12566
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12567
+ this.captureWhy(sessionId, cascade);
12568
+ this.socket.broadcast("session:error", {
12569
+ sessionId,
12570
+ error: err instanceof Error ? err.message : String(err)
12571
+ });
12572
+ throw err;
12573
+ } finally {
12574
+ this.activeSessions.delete(sessionId);
12575
+ }
12576
+ }
12321
12577
  /** Deny + clear any approvals still pending for a session (run end / abort). */
12322
12578
  denyPendingApprovals(sessionId) {
12323
12579
  for (const [id, pending] of this.pendingApprovals) {
@@ -12830,6 +13086,9 @@ ${prompt}`;
12830
13086
  cascade.on("peer:message", (e) => {
12831
13087
  this.socket.emitPeerMessage(e);
12832
13088
  });
13089
+ cascade.on("plan:approval-required", (e) => {
13090
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
13091
+ });
12833
13092
  try {
12834
13093
  const result = await cascade.run({
12835
13094
  prompt: runPrompt,
@@ -12837,7 +13096,8 @@ ${prompt}`;
12837
13096
  approvalCallback: this.makeApprovalCallback(sessionId)
12838
13097
  });
12839
13098
  this.recordSessionTask(sessionId, result.taskId);
12840
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
13099
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
13100
+ this.captureWhy(sessionId, cascade, result);
12841
13101
  this.socket.broadcast("cost:update", {
12842
13102
  sessionId,
12843
13103
  totalTokens: result.usage.totalTokens,
@@ -12847,6 +13107,7 @@ ${prompt}`;
12847
13107
  this.throttledBroadcast("workspace");
12848
13108
  } catch (err) {
12849
13109
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
13110
+ this.captureWhy(sessionId, cascade);
12850
13111
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
12851
13112
  sessionId,
12852
13113
  error: err instanceof Error ? err.message : String(err)
@@ -12868,6 +13129,168 @@ ${prompt}`;
12868
13129
  }))
12869
13130
  });
12870
13131
  });
13132
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
13133
+ const report = this.whyBySession.get(req.params.id);
13134
+ if (!report) {
13135
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
13136
+ return;
13137
+ }
13138
+ res.json(report);
13139
+ });
13140
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
13141
+ const sessionId = req.params.id;
13142
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13143
+ const before = /* @__PURE__ */ new Map();
13144
+ for (const taskId of taskIds) {
13145
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
13146
+ if (!before.has(filePath)) before.set(filePath, content);
13147
+ }
13148
+ }
13149
+ const { readFile } = await import('fs/promises');
13150
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
13151
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
13152
+ let after = "";
13153
+ let missing = false;
13154
+ try {
13155
+ const buf = await readFile(filePath);
13156
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
13157
+ } catch {
13158
+ missing = true;
13159
+ }
13160
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
13161
+ }));
13162
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
13163
+ });
13164
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
13165
+ const sessionId = req.params.id;
13166
+ const body = req.body;
13167
+ if (!body.filePath || typeof body.filePath !== "string") {
13168
+ res.status(400).json({ error: "filePath is required" });
13169
+ return;
13170
+ }
13171
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13172
+ let content;
13173
+ for (const taskId of taskIds) {
13174
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
13175
+ if (snap) {
13176
+ content = snap.content;
13177
+ break;
13178
+ }
13179
+ }
13180
+ if (content === void 0) {
13181
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
13182
+ return;
13183
+ }
13184
+ try {
13185
+ const { writeFile } = await import('fs/promises');
13186
+ await writeFile(body.filePath, content, "utf-8");
13187
+ res.json({ ok: true, filePath: body.filePath });
13188
+ } catch (err) {
13189
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13190
+ }
13191
+ });
13192
+ this.app.get("/api/costs", auth, (_req, res) => {
13193
+ try {
13194
+ const sessions = this.store.listSessions(void 0, 1e3);
13195
+ const budget = this.config.budget ?? { warnAtPct: 80 };
13196
+ res.json({
13197
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
13198
+ budget: {
13199
+ dailyBudgetUsd: budget.dailyBudgetUsd,
13200
+ sessionBudgetUsd: budget.sessionBudgetUsd,
13201
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
13202
+ }
13203
+ });
13204
+ } catch (err) {
13205
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13206
+ }
13207
+ });
13208
+ this.app.get("/api/schedules", auth, (_req, res) => {
13209
+ try {
13210
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
13211
+ } catch (err) {
13212
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13213
+ }
13214
+ });
13215
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
13216
+ const body = req.body;
13217
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
13218
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
13219
+ return;
13220
+ }
13221
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
13222
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13223
+ return;
13224
+ }
13225
+ const task = {
13226
+ id: crypto3.randomUUID(),
13227
+ name: body.name.trim(),
13228
+ cronExpression: body.cronExpression.trim(),
13229
+ prompt: body.prompt.trim(),
13230
+ workspacePath: this.workspacePath,
13231
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13232
+ enabled: body.enabled !== false
13233
+ };
13234
+ try {
13235
+ this.scheduler.add(task);
13236
+ res.json(task);
13237
+ } catch (err) {
13238
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13239
+ }
13240
+ });
13241
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13242
+ const id = req.params.id;
13243
+ const existing = this.scheduler.list().find((t) => t.id === id);
13244
+ if (!existing) {
13245
+ res.status(404).json({ error: "Not found" });
13246
+ return;
13247
+ }
13248
+ const body = req.body;
13249
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
13250
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13251
+ return;
13252
+ }
13253
+ const updated = {
13254
+ ...existing,
13255
+ name: body.name?.trim() || existing.name,
13256
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
13257
+ prompt: body.prompt?.trim() || existing.prompt,
13258
+ enabled: body.enabled ?? existing.enabled
13259
+ };
13260
+ try {
13261
+ this.scheduler.add(updated);
13262
+ if (!updated.enabled) this.scheduler.unschedule(id);
13263
+ res.json(updated);
13264
+ } catch (err) {
13265
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13266
+ }
13267
+ });
13268
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13269
+ try {
13270
+ this.scheduler.remove(req.params.id);
13271
+ res.json({ ok: true });
13272
+ } catch (err) {
13273
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13274
+ }
13275
+ });
13276
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
13277
+ try {
13278
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
13279
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
13280
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
13281
+ const logger = new AuditLogger3(this.workspacePath);
13282
+ try {
13283
+ const all = logger.getAllLogs();
13284
+ const total = all.length;
13285
+ const entries = all.reverse().slice(offset, offset + limit);
13286
+ res.json({ total, offset, entries });
13287
+ } finally {
13288
+ logger.close();
13289
+ }
13290
+ } catch (err) {
13291
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13292
+ }
13293
+ });
12871
13294
  const prodPath = path20__default.default.resolve(__dirname$1, "../web/dist");
12872
13295
  const devPath = path20__default.default.resolve(__dirname$1, "../../web/dist");
12873
13296
  const webDistPath = fs19__default.default.existsSync(prodPath) ? prodPath : devPath;
@@ -12889,68 +13312,6 @@ ${prompt}`;
12889
13312
  }
12890
13313
  }
12891
13314
  };
12892
- var TaskScheduler = class {
12893
- cronJobs = /* @__PURE__ */ new Map();
12894
- store;
12895
- runner;
12896
- constructor(store, runner) {
12897
- this.store = store;
12898
- this.runner = runner;
12899
- }
12900
- start() {
12901
- const tasks = this.store.listScheduledTasks();
12902
- for (const task of tasks) {
12903
- if (task.enabled) this.schedule(task);
12904
- }
12905
- }
12906
- stop() {
12907
- for (const job of this.cronJobs.values()) job.stop();
12908
- this.cronJobs.clear();
12909
- }
12910
- schedule(task) {
12911
- if (!cron__default.default.validate(task.cronExpression)) {
12912
- throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12913
- }
12914
- const existing = this.cronJobs.get(task.id);
12915
- if (existing) {
12916
- try {
12917
- existing.stop();
12918
- } catch {
12919
- }
12920
- }
12921
- const job = cron__default.default.schedule(task.cronExpression, async () => {
12922
- try {
12923
- task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12924
- this.store.saveScheduledTask(task);
12925
- await this.runner(task);
12926
- } catch (err) {
12927
- console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12928
- }
12929
- }, { timezone: "UTC" });
12930
- this.cronJobs.set(task.id, job);
12931
- }
12932
- unschedule(taskId) {
12933
- this.cronJobs.get(taskId)?.stop();
12934
- this.cronJobs.delete(taskId);
12935
- }
12936
- add(task) {
12937
- this.store.saveScheduledTask(task);
12938
- if (task.enabled) this.schedule(task);
12939
- }
12940
- remove(taskId) {
12941
- this.unschedule(taskId);
12942
- this.store.deleteScheduledTask(taskId);
12943
- }
12944
- list() {
12945
- return this.store.listScheduledTasks();
12946
- }
12947
- isRunning(taskId) {
12948
- return this.cronJobs.has(taskId);
12949
- }
12950
- static validateCron(expression) {
12951
- return cron__default.default.validate(expression);
12952
- }
12953
- };
12954
13315
  var execFileAsync2 = util.promisify(child_process.execFile);
12955
13316
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
12956
13317
  function sanitizeEnvValue(v) {