cascade-ai 0.15.2 → 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/cli.cjs +509 -12
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +508 -12
- package/dist/cli.js.map +1 -1
- package/dist/desktop-core.cjs +483 -74
- package/dist/index.cjs +480 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -4
- package/dist/index.d.ts +59 -4
- package/dist/index.js +480 -71
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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";
|
|
@@ -5552,6 +5552,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
5552
5552
|
const worker = new T3Worker(this.router, this.toolRegistry, this.id);
|
|
5553
5553
|
if (this.store) worker.setStore(this.store, taskId);
|
|
5554
5554
|
worker.setPeerBus(this.t3PeerBus);
|
|
5555
|
+
if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
|
|
5556
|
+
if (this.toolCreator) worker.setToolCreator(this.toolCreator);
|
|
5557
|
+
worker.on("log", (e) => this.emit("log", e));
|
|
5558
|
+
worker.on("tier:status", (e) => this.emit("tier:status", e));
|
|
5555
5559
|
worker.on("stream:token", (e) => this.emit("stream:token", e));
|
|
5556
5560
|
worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
|
|
5557
5561
|
...e,
|
|
@@ -5900,12 +5904,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
|
|
|
5900
5904
|
status: "IN_PROGRESS"
|
|
5901
5905
|
});
|
|
5902
5906
|
const okBefore = okCount(allT2Results);
|
|
5907
|
+
const completedSummary = this.summarizeCompletedSections(allT2Results);
|
|
5908
|
+
const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
|
|
5903
5909
|
const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
|
|
5904
5910
|
Review reason: ${reviewResult.reason}
|
|
5905
5911
|
|
|
5906
5912
|
Original goal: ${enrichedPrompt}
|
|
5907
5913
|
|
|
5908
|
-
Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections
|
|
5914
|
+
Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
|
|
5909
5915
|
const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
|
|
5910
5916
|
allT2Results = [...allT2Results, ...correctionResults];
|
|
5911
5917
|
if (okCount(allT2Results) <= okBefore) {
|
|
@@ -6008,6 +6014,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
|
|
|
6008
6014
|
return null;
|
|
6009
6015
|
}
|
|
6010
6016
|
}
|
|
6017
|
+
/** Structured, grounded summary of what's already done — used to keep
|
|
6018
|
+
* corrective replan passes from re-emitting completed sections. */
|
|
6019
|
+
summarizeCompletedSections(results) {
|
|
6020
|
+
const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
|
|
6021
|
+
if (done.length === 0) return "";
|
|
6022
|
+
const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
|
|
6023
|
+
return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
|
|
6024
|
+
${lines.join("\n")}`;
|
|
6025
|
+
}
|
|
6011
6026
|
async decomposeTask(prompt, systemContext) {
|
|
6012
6027
|
const db = this.router.getWorldStateDB?.();
|
|
6013
6028
|
let worldStateContext = "";
|
|
@@ -6275,7 +6290,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
|
|
|
6275
6290
|
}
|
|
6276
6291
|
}
|
|
6277
6292
|
if (readyIds.length === 0) return;
|
|
6278
|
-
|
|
6293
|
+
const runOne = async (id) => {
|
|
6279
6294
|
resultMap.set(id, null);
|
|
6280
6295
|
const index = sections.findIndex((s) => s.sectionId === id);
|
|
6281
6296
|
const section = sections[index];
|
|
@@ -6315,7 +6330,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
|
|
|
6315
6330
|
for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
|
|
6316
6331
|
inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
|
|
6317
6332
|
}
|
|
6318
|
-
}
|
|
6333
|
+
};
|
|
6334
|
+
if (this.router.getT3ExecutionMode?.() === "sequential") {
|
|
6335
|
+
for (const id of readyIds) {
|
|
6336
|
+
await runOne(id);
|
|
6337
|
+
}
|
|
6338
|
+
} else {
|
|
6339
|
+
await Promise.all(readyIds.map(runOne));
|
|
6340
|
+
}
|
|
6319
6341
|
if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
|
|
6320
6342
|
await executeWave();
|
|
6321
6343
|
}
|
|
@@ -8113,6 +8135,13 @@ var PermissionEscalator = class extends EventEmitter__default.default {
|
|
|
8113
8135
|
* All T3 workers under the same T2 share cached decisions for the same tool.
|
|
8114
8136
|
*/
|
|
8115
8137
|
sessionCache = /* @__PURE__ */ new Map();
|
|
8138
|
+
/**
|
|
8139
|
+
* Task-wide cache keyed by `toolName` alone, for USER- and T1-level
|
|
8140
|
+
* "always" decisions — these are meant to cover every sibling T2/T3 in the
|
|
8141
|
+
* run, not just the one that happened to ask first (see PermissionDecision
|
|
8142
|
+
* doc comment: "task-wide for T1").
|
|
8143
|
+
*/
|
|
8144
|
+
taskWideCache = /* @__PURE__ */ new Map();
|
|
8116
8145
|
t2Evaluator;
|
|
8117
8146
|
t1Evaluator;
|
|
8118
8147
|
/** Pending user-decision resolvers keyed by request ID */
|
|
@@ -8141,6 +8170,15 @@ var PermissionEscalator = class extends EventEmitter__default.default {
|
|
|
8141
8170
|
* Returns a PermissionDecision from whichever tier was able to decide.
|
|
8142
8171
|
*/
|
|
8143
8172
|
async requestPermission(req) {
|
|
8173
|
+
if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
|
|
8174
|
+
return {
|
|
8175
|
+
requestId: req.id,
|
|
8176
|
+
approved: this.taskWideCache.get(req.toolName),
|
|
8177
|
+
always: true,
|
|
8178
|
+
decidedBy: "T1",
|
|
8179
|
+
reasoning: "Cached from a previous task-wide decision in this session"
|
|
8180
|
+
};
|
|
8181
|
+
}
|
|
8144
8182
|
const cacheKey = `${req.parentT2Id}:${req.toolName}`;
|
|
8145
8183
|
if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
|
|
8146
8184
|
return {
|
|
@@ -8185,7 +8223,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
|
|
|
8185
8223
|
try {
|
|
8186
8224
|
const t1Decision = await this.t1Evaluator(req);
|
|
8187
8225
|
if (t1Decision !== null) {
|
|
8188
|
-
if (t1Decision.always) this.
|
|
8226
|
+
if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
|
|
8189
8227
|
return t1Decision;
|
|
8190
8228
|
}
|
|
8191
8229
|
} catch {
|
|
@@ -8215,7 +8253,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
|
|
|
8215
8253
|
const wrappedResolver = (decision) => {
|
|
8216
8254
|
if (timer) clearTimeout(timer);
|
|
8217
8255
|
if (decision.always) {
|
|
8218
|
-
this.
|
|
8256
|
+
this.taskWideCache.set(req.toolName, decision.approved);
|
|
8219
8257
|
}
|
|
8220
8258
|
resolve(decision);
|
|
8221
8259
|
};
|
|
@@ -11907,6 +11945,25 @@ var DashboardSocket = class {
|
|
|
11907
11945
|
});
|
|
11908
11946
|
});
|
|
11909
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
|
+
}
|
|
11910
11967
|
onSessionSteer(callback) {
|
|
11911
11968
|
this.io.on("connection", (socket) => {
|
|
11912
11969
|
socket.on("session:steer", (payload) => {
|
|
@@ -11948,6 +12005,122 @@ var DashboardSocket = class {
|
|
|
11948
12005
|
this.io.close();
|
|
11949
12006
|
}
|
|
11950
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
|
|
11951
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))));
|
|
11952
12125
|
var DashboardServer = class {
|
|
11953
12126
|
app;
|
|
@@ -11973,6 +12146,14 @@ var DashboardServer = class {
|
|
|
11973
12146
|
* map is how that answer reaches the run that's blocked on it.
|
|
11974
12147
|
*/
|
|
11975
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;
|
|
11976
12157
|
port;
|
|
11977
12158
|
host;
|
|
11978
12159
|
workspacePath;
|
|
@@ -11990,6 +12171,7 @@ var DashboardServer = class {
|
|
|
11990
12171
|
secret: this.dashboardSecret,
|
|
11991
12172
|
corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
|
|
11992
12173
|
});
|
|
12174
|
+
this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
|
|
11993
12175
|
this.setupMiddleware();
|
|
11994
12176
|
this.setupRoutes();
|
|
11995
12177
|
this.socket.onSessionRate((sessionId, rating) => {
|
|
@@ -12053,6 +12235,9 @@ var DashboardServer = class {
|
|
|
12053
12235
|
cascade.on("peer:message", (e) => {
|
|
12054
12236
|
this.socket.emitPeerMessage(e);
|
|
12055
12237
|
});
|
|
12238
|
+
cascade.on("plan:approval-required", (e) => {
|
|
12239
|
+
this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
|
|
12240
|
+
});
|
|
12056
12241
|
try {
|
|
12057
12242
|
const result = await cascade.run({
|
|
12058
12243
|
prompt: runPrompt,
|
|
@@ -12060,7 +12245,8 @@ var DashboardServer = class {
|
|
|
12060
12245
|
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12061
12246
|
});
|
|
12062
12247
|
this.recordSessionTask(sessionId, result.taskId);
|
|
12063
|
-
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
12248
|
+
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
12249
|
+
this.captureWhy(sessionId, cascade, result);
|
|
12064
12250
|
this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
|
|
12065
12251
|
this.socket.broadcast("cost:update", {
|
|
12066
12252
|
sessionId,
|
|
@@ -12070,6 +12256,7 @@ var DashboardServer = class {
|
|
|
12070
12256
|
this.throttledBroadcast("workspace");
|
|
12071
12257
|
} catch (err) {
|
|
12072
12258
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
12259
|
+
this.captureWhy(sessionId, cascade);
|
|
12073
12260
|
this.socket.emitToSocket(socketId, "session:error", {
|
|
12074
12261
|
sessionId,
|
|
12075
12262
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -12080,9 +12267,17 @@ var DashboardServer = class {
|
|
|
12080
12267
|
this.denyPendingApprovals(sessionId);
|
|
12081
12268
|
}
|
|
12082
12269
|
});
|
|
12270
|
+
this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
|
|
12271
|
+
this.activeSessions.get(sessionId)?.resolvePlanApproval(
|
|
12272
|
+
approved,
|
|
12273
|
+
note,
|
|
12274
|
+
editedPlan
|
|
12275
|
+
);
|
|
12276
|
+
});
|
|
12083
12277
|
this.socket.onSessionHalt((sessionId) => {
|
|
12084
12278
|
this.activeControllers.get(sessionId)?.abort();
|
|
12085
12279
|
this.denyPendingApprovals(sessionId);
|
|
12280
|
+
this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
|
|
12086
12281
|
});
|
|
12087
12282
|
this.socket.onApprovalResponse(({ requestId, approved, always }) => {
|
|
12088
12283
|
const pending = this.pendingApprovals.get(requestId);
|
|
@@ -12115,12 +12310,21 @@ var DashboardServer = class {
|
|
|
12115
12310
|
resolve();
|
|
12116
12311
|
});
|
|
12117
12312
|
});
|
|
12313
|
+
try {
|
|
12314
|
+
this.scheduler.start();
|
|
12315
|
+
} catch (err) {
|
|
12316
|
+
console.warn("[dashboard] failed to start task scheduler:", err);
|
|
12317
|
+
}
|
|
12118
12318
|
}
|
|
12119
12319
|
async stop() {
|
|
12120
12320
|
if (this.broadcastTimer) {
|
|
12121
12321
|
clearTimeout(this.broadcastTimer);
|
|
12122
12322
|
this.broadcastTimer = null;
|
|
12123
12323
|
}
|
|
12324
|
+
try {
|
|
12325
|
+
this.scheduler.stop();
|
|
12326
|
+
} catch {
|
|
12327
|
+
}
|
|
12124
12328
|
this.socket.close();
|
|
12125
12329
|
try {
|
|
12126
12330
|
this.globalStore?.close();
|
|
@@ -12134,6 +12338,16 @@ var DashboardServer = class {
|
|
|
12134
12338
|
getSocket() {
|
|
12135
12339
|
return this.socket;
|
|
12136
12340
|
}
|
|
12341
|
+
/**
|
|
12342
|
+
* Rebind the workspace tasks execute in — e.g. the desktop app's Code view
|
|
12343
|
+
* opening a different project folder — without tearing down the socket
|
|
12344
|
+
* server (which would drop the port/auth token/connection mid-session).
|
|
12345
|
+
* The next `cascade:run` picks this up immediately since `this.workspacePath`
|
|
12346
|
+
* is read live per-run (see onCascadeRun below).
|
|
12347
|
+
*/
|
|
12348
|
+
setWorkspacePath(workspacePath) {
|
|
12349
|
+
this.workspacePath = workspacePath;
|
|
12350
|
+
}
|
|
12137
12351
|
/**
|
|
12138
12352
|
* Write the in-memory config back to the workspace config file so mutations
|
|
12139
12353
|
* made over the socket (Settings → Save) persist across restarts. Best-effort:
|
|
@@ -12231,16 +12445,60 @@ var DashboardServer = class {
|
|
|
12231
12445
|
return title;
|
|
12232
12446
|
}
|
|
12233
12447
|
/** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
|
|
12234
|
-
persistRunEnd(sessionId, title, latestPrompt, reply, status) {
|
|
12448
|
+
persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
|
|
12235
12449
|
try {
|
|
12236
12450
|
if (reply && reply.trim()) {
|
|
12237
12451
|
this.store.addMessage({ id: crypto3.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
12238
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
|
+
}
|
|
12239
12467
|
} catch (err) {
|
|
12240
12468
|
console.warn("[dashboard] failed to persist run end:", err);
|
|
12241
12469
|
}
|
|
12242
12470
|
this.persistRuntimeRow(sessionId, title, status, latestPrompt);
|
|
12243
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
|
+
}
|
|
12244
12502
|
/**
|
|
12245
12503
|
* Route steering text into running Cascade instances. Targets the given
|
|
12246
12504
|
* session, or every active session when none is specified (the desktop
|
|
@@ -12270,6 +12528,52 @@ var DashboardServer = class {
|
|
|
12270
12528
|
this.pendingApprovals.set(request.id, { resolve, sessionId });
|
|
12271
12529
|
});
|
|
12272
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
|
+
}
|
|
12273
12577
|
/** Deny + clear any approvals still pending for a session (run end / abort). */
|
|
12274
12578
|
denyPendingApprovals(sessionId) {
|
|
12275
12579
|
for (const [id, pending] of this.pendingApprovals) {
|
|
@@ -12782,6 +13086,9 @@ ${prompt}`;
|
|
|
12782
13086
|
cascade.on("peer:message", (e) => {
|
|
12783
13087
|
this.socket.emitPeerMessage(e);
|
|
12784
13088
|
});
|
|
13089
|
+
cascade.on("plan:approval-required", (e) => {
|
|
13090
|
+
this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
|
|
13091
|
+
});
|
|
12785
13092
|
try {
|
|
12786
13093
|
const result = await cascade.run({
|
|
12787
13094
|
prompt: runPrompt,
|
|
@@ -12789,7 +13096,8 @@ ${prompt}`;
|
|
|
12789
13096
|
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12790
13097
|
});
|
|
12791
13098
|
this.recordSessionTask(sessionId, result.taskId);
|
|
12792
|
-
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
13099
|
+
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
13100
|
+
this.captureWhy(sessionId, cascade, result);
|
|
12793
13101
|
this.socket.broadcast("cost:update", {
|
|
12794
13102
|
sessionId,
|
|
12795
13103
|
totalTokens: result.usage.totalTokens,
|
|
@@ -12799,6 +13107,7 @@ ${prompt}`;
|
|
|
12799
13107
|
this.throttledBroadcast("workspace");
|
|
12800
13108
|
} catch (err) {
|
|
12801
13109
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
13110
|
+
this.captureWhy(sessionId, cascade);
|
|
12802
13111
|
this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
|
|
12803
13112
|
sessionId,
|
|
12804
13113
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -12820,6 +13129,168 @@ ${prompt}`;
|
|
|
12820
13129
|
}))
|
|
12821
13130
|
});
|
|
12822
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
|
+
});
|
|
12823
13294
|
const prodPath = path20__default.default.resolve(__dirname$1, "../web/dist");
|
|
12824
13295
|
const devPath = path20__default.default.resolve(__dirname$1, "../../web/dist");
|
|
12825
13296
|
const webDistPath = fs19__default.default.existsSync(prodPath) ? prodPath : devPath;
|
|
@@ -12841,68 +13312,6 @@ ${prompt}`;
|
|
|
12841
13312
|
}
|
|
12842
13313
|
}
|
|
12843
13314
|
};
|
|
12844
|
-
var TaskScheduler = class {
|
|
12845
|
-
cronJobs = /* @__PURE__ */ new Map();
|
|
12846
|
-
store;
|
|
12847
|
-
runner;
|
|
12848
|
-
constructor(store, runner) {
|
|
12849
|
-
this.store = store;
|
|
12850
|
-
this.runner = runner;
|
|
12851
|
-
}
|
|
12852
|
-
start() {
|
|
12853
|
-
const tasks = this.store.listScheduledTasks();
|
|
12854
|
-
for (const task of tasks) {
|
|
12855
|
-
if (task.enabled) this.schedule(task);
|
|
12856
|
-
}
|
|
12857
|
-
}
|
|
12858
|
-
stop() {
|
|
12859
|
-
for (const job of this.cronJobs.values()) job.stop();
|
|
12860
|
-
this.cronJobs.clear();
|
|
12861
|
-
}
|
|
12862
|
-
schedule(task) {
|
|
12863
|
-
if (!cron__default.default.validate(task.cronExpression)) {
|
|
12864
|
-
throw new Error(`Invalid cron expression: ${task.cronExpression}`);
|
|
12865
|
-
}
|
|
12866
|
-
const existing = this.cronJobs.get(task.id);
|
|
12867
|
-
if (existing) {
|
|
12868
|
-
try {
|
|
12869
|
-
existing.stop();
|
|
12870
|
-
} catch {
|
|
12871
|
-
}
|
|
12872
|
-
}
|
|
12873
|
-
const job = cron__default.default.schedule(task.cronExpression, async () => {
|
|
12874
|
-
try {
|
|
12875
|
-
task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
|
|
12876
|
-
this.store.saveScheduledTask(task);
|
|
12877
|
-
await this.runner(task);
|
|
12878
|
-
} catch (err) {
|
|
12879
|
-
console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
|
|
12880
|
-
}
|
|
12881
|
-
}, { timezone: "UTC" });
|
|
12882
|
-
this.cronJobs.set(task.id, job);
|
|
12883
|
-
}
|
|
12884
|
-
unschedule(taskId) {
|
|
12885
|
-
this.cronJobs.get(taskId)?.stop();
|
|
12886
|
-
this.cronJobs.delete(taskId);
|
|
12887
|
-
}
|
|
12888
|
-
add(task) {
|
|
12889
|
-
this.store.saveScheduledTask(task);
|
|
12890
|
-
if (task.enabled) this.schedule(task);
|
|
12891
|
-
}
|
|
12892
|
-
remove(taskId) {
|
|
12893
|
-
this.unschedule(taskId);
|
|
12894
|
-
this.store.deleteScheduledTask(taskId);
|
|
12895
|
-
}
|
|
12896
|
-
list() {
|
|
12897
|
-
return this.store.listScheduledTasks();
|
|
12898
|
-
}
|
|
12899
|
-
isRunning(taskId) {
|
|
12900
|
-
return this.cronJobs.has(taskId);
|
|
12901
|
-
}
|
|
12902
|
-
static validateCron(expression) {
|
|
12903
|
-
return cron__default.default.validate(expression);
|
|
12904
|
-
}
|
|
12905
|
-
};
|
|
12906
13315
|
var execFileAsync2 = util.promisify(child_process.execFile);
|
|
12907
13316
|
var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
12908
13317
|
function sanitizeEnvValue(v) {
|