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.js CHANGED
@@ -190,7 +190,7 @@ var init_audit_logger = __esm({
190
190
  });
191
191
 
192
192
  // src/constants.ts
193
- var CASCADE_VERSION = "0.16.0";
193
+ var CASCADE_VERSION = "0.17.0";
194
194
  var CASCADE_CONFIG_DIR = ".cascade";
195
195
  var CASCADE_MD_FILE = "CASCADE.md";
196
196
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -11899,6 +11899,25 @@ var DashboardSocket = class {
11899
11899
  });
11900
11900
  });
11901
11901
  }
11902
+ /**
11903
+ * Boardroom plan decisions from a connected client. The desktop shows a
11904
+ * plan-review modal on `plan:approval-required` and answers here; the
11905
+ * server routes the decision into the paused run via resolvePlanApproval.
11906
+ */
11907
+ onPlanDecision(callback) {
11908
+ this.io.on("connection", (socket) => {
11909
+ socket.on("plan:decision", (payload) => {
11910
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
11911
+ callback({
11912
+ sessionId: payload.sessionId,
11913
+ approved: payload.approved,
11914
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
11915
+ editedPlan: payload.editedPlan
11916
+ });
11917
+ }
11918
+ });
11919
+ });
11920
+ }
11902
11921
  onSessionSteer(callback) {
11903
11922
  this.io.on("connection", (socket) => {
11904
11923
  socket.on("session:steer", (payload) => {
@@ -11940,6 +11959,122 @@ var DashboardSocket = class {
11940
11959
  this.io.close();
11941
11960
  }
11942
11961
  };
11962
+ var TaskScheduler = class {
11963
+ cronJobs = /* @__PURE__ */ new Map();
11964
+ store;
11965
+ runner;
11966
+ constructor(store, runner) {
11967
+ this.store = store;
11968
+ this.runner = runner;
11969
+ }
11970
+ start() {
11971
+ const tasks = this.store.listScheduledTasks();
11972
+ for (const task of tasks) {
11973
+ if (task.enabled) this.schedule(task);
11974
+ }
11975
+ }
11976
+ stop() {
11977
+ for (const job of this.cronJobs.values()) job.stop();
11978
+ this.cronJobs.clear();
11979
+ }
11980
+ schedule(task) {
11981
+ if (!cron.validate(task.cronExpression)) {
11982
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
11983
+ }
11984
+ const existing = this.cronJobs.get(task.id);
11985
+ if (existing) {
11986
+ try {
11987
+ existing.stop();
11988
+ } catch {
11989
+ }
11990
+ }
11991
+ const job = cron.schedule(task.cronExpression, async () => {
11992
+ try {
11993
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
11994
+ this.store.saveScheduledTask(task);
11995
+ await this.runner(task);
11996
+ } catch (err) {
11997
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
11998
+ }
11999
+ }, { timezone: "UTC" });
12000
+ this.cronJobs.set(task.id, job);
12001
+ }
12002
+ unschedule(taskId) {
12003
+ this.cronJobs.get(taskId)?.stop();
12004
+ this.cronJobs.delete(taskId);
12005
+ }
12006
+ add(task) {
12007
+ this.store.saveScheduledTask(task);
12008
+ if (task.enabled) this.schedule(task);
12009
+ }
12010
+ remove(taskId) {
12011
+ this.unschedule(taskId);
12012
+ this.store.deleteScheduledTask(taskId);
12013
+ }
12014
+ list() {
12015
+ return this.store.listScheduledTasks();
12016
+ }
12017
+ isRunning(taskId) {
12018
+ return this.cronJobs.has(taskId);
12019
+ }
12020
+ static validateCron(expression) {
12021
+ return cron.validate(expression);
12022
+ }
12023
+ };
12024
+
12025
+ // src/dashboard/cost-stats.ts
12026
+ var DAY_MS = 24 * 60 * 60 * 1e3;
12027
+ function utcDateKey(d) {
12028
+ return d.toISOString().slice(0, 10);
12029
+ }
12030
+ function aggregateCostStats(sessions, opts = {}) {
12031
+ const days = Math.max(1, opts.days ?? 30);
12032
+ const topN = Math.max(1, opts.topN ?? 8);
12033
+ const now = opts.now ?? /* @__PURE__ */ new Date();
12034
+ const buckets = /* @__PURE__ */ new Map();
12035
+ for (let i = days - 1; i >= 0; i--) {
12036
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
12037
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
12038
+ }
12039
+ let totalCostUsd = 0;
12040
+ let totalTokens = 0;
12041
+ let totalRuns = 0;
12042
+ for (const s of sessions) {
12043
+ const cost = s.metadata?.totalCostUsd ?? 0;
12044
+ const tokens = s.metadata?.totalTokens ?? 0;
12045
+ const runs = s.metadata?.taskCount ?? 0;
12046
+ totalCostUsd += cost;
12047
+ totalTokens += tokens;
12048
+ totalRuns += runs;
12049
+ const when = new Date(s.updatedAt || s.createdAt);
12050
+ if (!Number.isNaN(when.getTime())) {
12051
+ const bucket = buckets.get(utcDateKey(when));
12052
+ if (bucket) {
12053
+ bucket.costUsd += cost;
12054
+ bucket.tokens += tokens;
12055
+ bucket.runs += runs;
12056
+ }
12057
+ }
12058
+ }
12059
+ 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) => ({
12060
+ sessionId: s.id,
12061
+ title: s.title,
12062
+ costUsd: s.metadata?.totalCostUsd ?? 0,
12063
+ tokens: s.metadata?.totalTokens ?? 0,
12064
+ runs: s.metadata?.taskCount ?? 0,
12065
+ updatedAt: s.updatedAt
12066
+ }));
12067
+ return {
12068
+ totalCostUsd,
12069
+ totalTokens,
12070
+ totalSessions: sessions.length,
12071
+ totalRuns,
12072
+ perDay: [...buckets.values()],
12073
+ topSessions
12074
+ };
12075
+ }
12076
+
12077
+ // src/dashboard/server.ts
11943
12078
  var __dirname$1 = path20.dirname(fileURLToPath(import.meta.url));
11944
12079
  var DashboardServer = class {
11945
12080
  app;
@@ -11965,6 +12100,14 @@ var DashboardServer = class {
11965
12100
  * map is how that answer reaches the run that's blocked on it.
11966
12101
  */
11967
12102
  pendingApprovals = /* @__PURE__ */ new Map();
12103
+ /**
12104
+ * The orchestration decision trail ("why") of each session's most recent
12105
+ * run — captured when the run ends so the desktop's Why panel can show it
12106
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
12107
+ */
12108
+ whyBySession = /* @__PURE__ */ new Map();
12109
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
12110
+ scheduler;
11968
12111
  port;
11969
12112
  host;
11970
12113
  workspacePath;
@@ -11982,6 +12125,7 @@ var DashboardServer = class {
11982
12125
  secret: this.dashboardSecret,
11983
12126
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
11984
12127
  });
12128
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
11985
12129
  this.setupMiddleware();
11986
12130
  this.setupRoutes();
11987
12131
  this.socket.onSessionRate((sessionId, rating) => {
@@ -12045,6 +12189,9 @@ var DashboardServer = class {
12045
12189
  cascade.on("peer:message", (e) => {
12046
12190
  this.socket.emitPeerMessage(e);
12047
12191
  });
12192
+ cascade.on("plan:approval-required", (e) => {
12193
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
12194
+ });
12048
12195
  try {
12049
12196
  const result = await cascade.run({
12050
12197
  prompt: runPrompt,
@@ -12052,7 +12199,8 @@ var DashboardServer = class {
12052
12199
  approvalCallback: this.makeApprovalCallback(sessionId)
12053
12200
  });
12054
12201
  this.recordSessionTask(sessionId, result.taskId);
12055
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
12202
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12203
+ this.captureWhy(sessionId, cascade, result);
12056
12204
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
12057
12205
  this.socket.broadcast("cost:update", {
12058
12206
  sessionId,
@@ -12062,6 +12210,7 @@ var DashboardServer = class {
12062
12210
  this.throttledBroadcast("workspace");
12063
12211
  } catch (err) {
12064
12212
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12213
+ this.captureWhy(sessionId, cascade);
12065
12214
  this.socket.emitToSocket(socketId, "session:error", {
12066
12215
  sessionId,
12067
12216
  error: err instanceof Error ? err.message : String(err)
@@ -12072,9 +12221,17 @@ var DashboardServer = class {
12072
12221
  this.denyPendingApprovals(sessionId);
12073
12222
  }
12074
12223
  });
12224
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
12225
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
12226
+ approved,
12227
+ note,
12228
+ editedPlan
12229
+ );
12230
+ });
12075
12231
  this.socket.onSessionHalt((sessionId) => {
12076
12232
  this.activeControllers.get(sessionId)?.abort();
12077
12233
  this.denyPendingApprovals(sessionId);
12234
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
12078
12235
  });
12079
12236
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
12080
12237
  const pending = this.pendingApprovals.get(requestId);
@@ -12107,12 +12264,21 @@ var DashboardServer = class {
12107
12264
  resolve();
12108
12265
  });
12109
12266
  });
12267
+ try {
12268
+ this.scheduler.start();
12269
+ } catch (err) {
12270
+ console.warn("[dashboard] failed to start task scheduler:", err);
12271
+ }
12110
12272
  }
12111
12273
  async stop() {
12112
12274
  if (this.broadcastTimer) {
12113
12275
  clearTimeout(this.broadcastTimer);
12114
12276
  this.broadcastTimer = null;
12115
12277
  }
12278
+ try {
12279
+ this.scheduler.stop();
12280
+ } catch {
12281
+ }
12116
12282
  this.socket.close();
12117
12283
  try {
12118
12284
  this.globalStore?.close();
@@ -12233,16 +12399,60 @@ var DashboardServer = class {
12233
12399
  return title;
12234
12400
  }
12235
12401
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
12236
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
12402
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
12237
12403
  try {
12238
12404
  if (reply && reply.trim()) {
12239
12405
  this.store.addMessage({ id: randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
12240
12406
  }
12407
+ if (result) {
12408
+ const session = this.store.getSession(sessionId);
12409
+ if (session) {
12410
+ this.store.updateSession(sessionId, {
12411
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12412
+ metadata: {
12413
+ ...session.metadata,
12414
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
12415
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
12416
+ taskCount: session.metadata.taskCount + 1
12417
+ }
12418
+ });
12419
+ }
12420
+ }
12241
12421
  } catch (err) {
12242
12422
  console.warn("[dashboard] failed to persist run end:", err);
12243
12423
  }
12244
12424
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
12245
12425
  }
12426
+ /**
12427
+ * Capture the run's decision trail + router economics ("why") and broadcast
12428
+ * it so the desktop's Why panel updates live; kept per-session for the
12429
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
12430
+ */
12431
+ captureWhy(sessionId, cascade, result) {
12432
+ try {
12433
+ const stats = cascade.getRouter().getStats();
12434
+ const savings = cascade.getRouter().getDelegationSavings();
12435
+ const report = {
12436
+ sessionId,
12437
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
12438
+ decisions: cascade.getDecisionLog(),
12439
+ savedUsd: savings.savedUsd,
12440
+ savedPct: savings.savedPct,
12441
+ totalCostUsd: stats.totalCostUsd,
12442
+ totalTokens: stats.totalTokens,
12443
+ costByTier: stats.costByTier,
12444
+ durationMs: result?.durationMs
12445
+ };
12446
+ this.whyBySession.set(sessionId, report);
12447
+ if (this.whyBySession.size > 50) {
12448
+ const oldest = this.whyBySession.keys().next().value;
12449
+ if (oldest) this.whyBySession.delete(oldest);
12450
+ }
12451
+ this.socket.broadcast("run:why", report);
12452
+ } catch (err) {
12453
+ console.warn("[dashboard] failed to capture decision trail:", err);
12454
+ }
12455
+ }
12246
12456
  /**
12247
12457
  * Route steering text into running Cascade instances. Targets the given
12248
12458
  * session, or every active session when none is specified (the desktop
@@ -12272,6 +12482,52 @@ var DashboardServer = class {
12272
12482
  this.pendingApprovals.set(request.id, { resolve, sessionId });
12273
12483
  });
12274
12484
  }
12485
+ /**
12486
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
12487
+ * tool approvals are auto-granted since nobody may be watching when the cron
12488
+ * fires — the Schedules UI states this. Events broadcast to every connected
12489
+ * client so an open desktop sees the run appear live.
12490
+ */
12491
+ async runScheduledTask(task) {
12492
+ const sessionId = randomUUID();
12493
+ const prompt = task.prompt;
12494
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
12495
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
12496
+ this.activeSessions.set(sessionId, cascade);
12497
+ cascade.on("tier:status", (e) => {
12498
+ this.socket.broadcast("tier:status", { sessionId, ...e });
12499
+ });
12500
+ cascade.on("peer:message", (e) => {
12501
+ this.socket.emitPeerMessage(e);
12502
+ });
12503
+ try {
12504
+ const result = await cascade.run({
12505
+ prompt,
12506
+ identityId: task.identityId,
12507
+ approvalCallback: async () => ({ approved: true, always: false })
12508
+ });
12509
+ this.recordSessionTask(sessionId, result.taskId);
12510
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12511
+ this.captureWhy(sessionId, cascade, result);
12512
+ this.socket.broadcast("session:complete", { sessionId, result });
12513
+ this.socket.broadcast("cost:update", {
12514
+ sessionId,
12515
+ totalTokens: result.usage.totalTokens,
12516
+ totalCostUsd: result.usage.estimatedCostUsd
12517
+ });
12518
+ this.throttledBroadcast("workspace");
12519
+ } catch (err) {
12520
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12521
+ this.captureWhy(sessionId, cascade);
12522
+ this.socket.broadcast("session:error", {
12523
+ sessionId,
12524
+ error: err instanceof Error ? err.message : String(err)
12525
+ });
12526
+ throw err;
12527
+ } finally {
12528
+ this.activeSessions.delete(sessionId);
12529
+ }
12530
+ }
12275
12531
  /** Deny + clear any approvals still pending for a session (run end / abort). */
12276
12532
  denyPendingApprovals(sessionId) {
12277
12533
  for (const [id, pending] of this.pendingApprovals) {
@@ -12784,6 +13040,9 @@ ${prompt}`;
12784
13040
  cascade.on("peer:message", (e) => {
12785
13041
  this.socket.emitPeerMessage(e);
12786
13042
  });
13043
+ cascade.on("plan:approval-required", (e) => {
13044
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
13045
+ });
12787
13046
  try {
12788
13047
  const result = await cascade.run({
12789
13048
  prompt: runPrompt,
@@ -12791,7 +13050,8 @@ ${prompt}`;
12791
13050
  approvalCallback: this.makeApprovalCallback(sessionId)
12792
13051
  });
12793
13052
  this.recordSessionTask(sessionId, result.taskId);
12794
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
13053
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
13054
+ this.captureWhy(sessionId, cascade, result);
12795
13055
  this.socket.broadcast("cost:update", {
12796
13056
  sessionId,
12797
13057
  totalTokens: result.usage.totalTokens,
@@ -12801,6 +13061,7 @@ ${prompt}`;
12801
13061
  this.throttledBroadcast("workspace");
12802
13062
  } catch (err) {
12803
13063
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
13064
+ this.captureWhy(sessionId, cascade);
12804
13065
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
12805
13066
  sessionId,
12806
13067
  error: err instanceof Error ? err.message : String(err)
@@ -12822,6 +13083,168 @@ ${prompt}`;
12822
13083
  }))
12823
13084
  });
12824
13085
  });
13086
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
13087
+ const report = this.whyBySession.get(req.params.id);
13088
+ if (!report) {
13089
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
13090
+ return;
13091
+ }
13092
+ res.json(report);
13093
+ });
13094
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
13095
+ const sessionId = req.params.id;
13096
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13097
+ const before = /* @__PURE__ */ new Map();
13098
+ for (const taskId of taskIds) {
13099
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
13100
+ if (!before.has(filePath)) before.set(filePath, content);
13101
+ }
13102
+ }
13103
+ const { readFile } = await import('fs/promises');
13104
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
13105
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
13106
+ let after = "";
13107
+ let missing = false;
13108
+ try {
13109
+ const buf = await readFile(filePath);
13110
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
13111
+ } catch {
13112
+ missing = true;
13113
+ }
13114
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
13115
+ }));
13116
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
13117
+ });
13118
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
13119
+ const sessionId = req.params.id;
13120
+ const body = req.body;
13121
+ if (!body.filePath || typeof body.filePath !== "string") {
13122
+ res.status(400).json({ error: "filePath is required" });
13123
+ return;
13124
+ }
13125
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13126
+ let content;
13127
+ for (const taskId of taskIds) {
13128
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
13129
+ if (snap) {
13130
+ content = snap.content;
13131
+ break;
13132
+ }
13133
+ }
13134
+ if (content === void 0) {
13135
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
13136
+ return;
13137
+ }
13138
+ try {
13139
+ const { writeFile } = await import('fs/promises');
13140
+ await writeFile(body.filePath, content, "utf-8");
13141
+ res.json({ ok: true, filePath: body.filePath });
13142
+ } catch (err) {
13143
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13144
+ }
13145
+ });
13146
+ this.app.get("/api/costs", auth, (_req, res) => {
13147
+ try {
13148
+ const sessions = this.store.listSessions(void 0, 1e3);
13149
+ const budget = this.config.budget ?? { warnAtPct: 80 };
13150
+ res.json({
13151
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
13152
+ budget: {
13153
+ dailyBudgetUsd: budget.dailyBudgetUsd,
13154
+ sessionBudgetUsd: budget.sessionBudgetUsd,
13155
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
13156
+ }
13157
+ });
13158
+ } catch (err) {
13159
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13160
+ }
13161
+ });
13162
+ this.app.get("/api/schedules", auth, (_req, res) => {
13163
+ try {
13164
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
13165
+ } catch (err) {
13166
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13167
+ }
13168
+ });
13169
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
13170
+ const body = req.body;
13171
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
13172
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
13173
+ return;
13174
+ }
13175
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
13176
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13177
+ return;
13178
+ }
13179
+ const task = {
13180
+ id: randomUUID(),
13181
+ name: body.name.trim(),
13182
+ cronExpression: body.cronExpression.trim(),
13183
+ prompt: body.prompt.trim(),
13184
+ workspacePath: this.workspacePath,
13185
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13186
+ enabled: body.enabled !== false
13187
+ };
13188
+ try {
13189
+ this.scheduler.add(task);
13190
+ res.json(task);
13191
+ } catch (err) {
13192
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13193
+ }
13194
+ });
13195
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13196
+ const id = req.params.id;
13197
+ const existing = this.scheduler.list().find((t) => t.id === id);
13198
+ if (!existing) {
13199
+ res.status(404).json({ error: "Not found" });
13200
+ return;
13201
+ }
13202
+ const body = req.body;
13203
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
13204
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13205
+ return;
13206
+ }
13207
+ const updated = {
13208
+ ...existing,
13209
+ name: body.name?.trim() || existing.name,
13210
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
13211
+ prompt: body.prompt?.trim() || existing.prompt,
13212
+ enabled: body.enabled ?? existing.enabled
13213
+ };
13214
+ try {
13215
+ this.scheduler.add(updated);
13216
+ if (!updated.enabled) this.scheduler.unschedule(id);
13217
+ res.json(updated);
13218
+ } catch (err) {
13219
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13220
+ }
13221
+ });
13222
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13223
+ try {
13224
+ this.scheduler.remove(req.params.id);
13225
+ res.json({ ok: true });
13226
+ } catch (err) {
13227
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13228
+ }
13229
+ });
13230
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
13231
+ try {
13232
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
13233
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
13234
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
13235
+ const logger = new AuditLogger3(this.workspacePath);
13236
+ try {
13237
+ const all = logger.getAllLogs();
13238
+ const total = all.length;
13239
+ const entries = all.reverse().slice(offset, offset + limit);
13240
+ res.json({ total, offset, entries });
13241
+ } finally {
13242
+ logger.close();
13243
+ }
13244
+ } catch (err) {
13245
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13246
+ }
13247
+ });
12825
13248
  const prodPath = path20.resolve(__dirname$1, "../web/dist");
12826
13249
  const devPath = path20.resolve(__dirname$1, "../../web/dist");
12827
13250
  const webDistPath = fs19.existsSync(prodPath) ? prodPath : devPath;
@@ -12843,68 +13266,6 @@ ${prompt}`;
12843
13266
  }
12844
13267
  }
12845
13268
  };
12846
- var TaskScheduler = class {
12847
- cronJobs = /* @__PURE__ */ new Map();
12848
- store;
12849
- runner;
12850
- constructor(store, runner) {
12851
- this.store = store;
12852
- this.runner = runner;
12853
- }
12854
- start() {
12855
- const tasks = this.store.listScheduledTasks();
12856
- for (const task of tasks) {
12857
- if (task.enabled) this.schedule(task);
12858
- }
12859
- }
12860
- stop() {
12861
- for (const job of this.cronJobs.values()) job.stop();
12862
- this.cronJobs.clear();
12863
- }
12864
- schedule(task) {
12865
- if (!cron.validate(task.cronExpression)) {
12866
- throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12867
- }
12868
- const existing = this.cronJobs.get(task.id);
12869
- if (existing) {
12870
- try {
12871
- existing.stop();
12872
- } catch {
12873
- }
12874
- }
12875
- const job = cron.schedule(task.cronExpression, async () => {
12876
- try {
12877
- task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12878
- this.store.saveScheduledTask(task);
12879
- await this.runner(task);
12880
- } catch (err) {
12881
- console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12882
- }
12883
- }, { timezone: "UTC" });
12884
- this.cronJobs.set(task.id, job);
12885
- }
12886
- unschedule(taskId) {
12887
- this.cronJobs.get(taskId)?.stop();
12888
- this.cronJobs.delete(taskId);
12889
- }
12890
- add(task) {
12891
- this.store.saveScheduledTask(task);
12892
- if (task.enabled) this.schedule(task);
12893
- }
12894
- remove(taskId) {
12895
- this.unschedule(taskId);
12896
- this.store.deleteScheduledTask(taskId);
12897
- }
12898
- list() {
12899
- return this.store.listScheduledTasks();
12900
- }
12901
- isRunning(taskId) {
12902
- return this.cronJobs.has(taskId);
12903
- }
12904
- static validateCron(expression) {
12905
- return cron.validate(expression);
12906
- }
12907
- };
12908
13269
  var execFileAsync2 = promisify(execFile);
12909
13270
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
12910
13271
  function sanitizeEnvValue(v) {