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.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.
|
|
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";
|
|
@@ -5506,6 +5506,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
5506
5506
|
const worker = new T3Worker(this.router, this.toolRegistry, this.id);
|
|
5507
5507
|
if (this.store) worker.setStore(this.store, taskId);
|
|
5508
5508
|
worker.setPeerBus(this.t3PeerBus);
|
|
5509
|
+
if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
|
|
5510
|
+
if (this.toolCreator) worker.setToolCreator(this.toolCreator);
|
|
5511
|
+
worker.on("log", (e) => this.emit("log", e));
|
|
5512
|
+
worker.on("tier:status", (e) => this.emit("tier:status", e));
|
|
5509
5513
|
worker.on("stream:token", (e) => this.emit("stream:token", e));
|
|
5510
5514
|
worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
|
|
5511
5515
|
...e,
|
|
@@ -5854,12 +5858,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
|
|
|
5854
5858
|
status: "IN_PROGRESS"
|
|
5855
5859
|
});
|
|
5856
5860
|
const okBefore = okCount(allT2Results);
|
|
5861
|
+
const completedSummary = this.summarizeCompletedSections(allT2Results);
|
|
5862
|
+
const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
|
|
5857
5863
|
const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
|
|
5858
5864
|
Review reason: ${reviewResult.reason}
|
|
5859
5865
|
|
|
5860
5866
|
Original goal: ${enrichedPrompt}
|
|
5861
5867
|
|
|
5862
|
-
Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections
|
|
5868
|
+
Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
|
|
5863
5869
|
const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
|
|
5864
5870
|
allT2Results = [...allT2Results, ...correctionResults];
|
|
5865
5871
|
if (okCount(allT2Results) <= okBefore) {
|
|
@@ -5962,6 +5968,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
|
|
|
5962
5968
|
return null;
|
|
5963
5969
|
}
|
|
5964
5970
|
}
|
|
5971
|
+
/** Structured, grounded summary of what's already done — used to keep
|
|
5972
|
+
* corrective replan passes from re-emitting completed sections. */
|
|
5973
|
+
summarizeCompletedSections(results) {
|
|
5974
|
+
const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
|
|
5975
|
+
if (done.length === 0) return "";
|
|
5976
|
+
const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
|
|
5977
|
+
return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
|
|
5978
|
+
${lines.join("\n")}`;
|
|
5979
|
+
}
|
|
5965
5980
|
async decomposeTask(prompt, systemContext) {
|
|
5966
5981
|
const db = this.router.getWorldStateDB?.();
|
|
5967
5982
|
let worldStateContext = "";
|
|
@@ -6229,7 +6244,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
|
|
|
6229
6244
|
}
|
|
6230
6245
|
}
|
|
6231
6246
|
if (readyIds.length === 0) return;
|
|
6232
|
-
|
|
6247
|
+
const runOne = async (id) => {
|
|
6233
6248
|
resultMap.set(id, null);
|
|
6234
6249
|
const index = sections.findIndex((s) => s.sectionId === id);
|
|
6235
6250
|
const section = sections[index];
|
|
@@ -6269,7 +6284,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
|
|
|
6269
6284
|
for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
|
|
6270
6285
|
inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
|
|
6271
6286
|
}
|
|
6272
|
-
}
|
|
6287
|
+
};
|
|
6288
|
+
if (this.router.getT3ExecutionMode?.() === "sequential") {
|
|
6289
|
+
for (const id of readyIds) {
|
|
6290
|
+
await runOne(id);
|
|
6291
|
+
}
|
|
6292
|
+
} else {
|
|
6293
|
+
await Promise.all(readyIds.map(runOne));
|
|
6294
|
+
}
|
|
6273
6295
|
if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
|
|
6274
6296
|
await executeWave();
|
|
6275
6297
|
}
|
|
@@ -8067,6 +8089,13 @@ var PermissionEscalator = class extends EventEmitter {
|
|
|
8067
8089
|
* All T3 workers under the same T2 share cached decisions for the same tool.
|
|
8068
8090
|
*/
|
|
8069
8091
|
sessionCache = /* @__PURE__ */ new Map();
|
|
8092
|
+
/**
|
|
8093
|
+
* Task-wide cache keyed by `toolName` alone, for USER- and T1-level
|
|
8094
|
+
* "always" decisions — these are meant to cover every sibling T2/T3 in the
|
|
8095
|
+
* run, not just the one that happened to ask first (see PermissionDecision
|
|
8096
|
+
* doc comment: "task-wide for T1").
|
|
8097
|
+
*/
|
|
8098
|
+
taskWideCache = /* @__PURE__ */ new Map();
|
|
8070
8099
|
t2Evaluator;
|
|
8071
8100
|
t1Evaluator;
|
|
8072
8101
|
/** Pending user-decision resolvers keyed by request ID */
|
|
@@ -8095,6 +8124,15 @@ var PermissionEscalator = class extends EventEmitter {
|
|
|
8095
8124
|
* Returns a PermissionDecision from whichever tier was able to decide.
|
|
8096
8125
|
*/
|
|
8097
8126
|
async requestPermission(req) {
|
|
8127
|
+
if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
|
|
8128
|
+
return {
|
|
8129
|
+
requestId: req.id,
|
|
8130
|
+
approved: this.taskWideCache.get(req.toolName),
|
|
8131
|
+
always: true,
|
|
8132
|
+
decidedBy: "T1",
|
|
8133
|
+
reasoning: "Cached from a previous task-wide decision in this session"
|
|
8134
|
+
};
|
|
8135
|
+
}
|
|
8098
8136
|
const cacheKey = `${req.parentT2Id}:${req.toolName}`;
|
|
8099
8137
|
if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
|
|
8100
8138
|
return {
|
|
@@ -8139,7 +8177,7 @@ var PermissionEscalator = class extends EventEmitter {
|
|
|
8139
8177
|
try {
|
|
8140
8178
|
const t1Decision = await this.t1Evaluator(req);
|
|
8141
8179
|
if (t1Decision !== null) {
|
|
8142
|
-
if (t1Decision.always) this.
|
|
8180
|
+
if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
|
|
8143
8181
|
return t1Decision;
|
|
8144
8182
|
}
|
|
8145
8183
|
} catch {
|
|
@@ -8169,7 +8207,7 @@ var PermissionEscalator = class extends EventEmitter {
|
|
|
8169
8207
|
const wrappedResolver = (decision) => {
|
|
8170
8208
|
if (timer) clearTimeout(timer);
|
|
8171
8209
|
if (decision.always) {
|
|
8172
|
-
this.
|
|
8210
|
+
this.taskWideCache.set(req.toolName, decision.approved);
|
|
8173
8211
|
}
|
|
8174
8212
|
resolve(decision);
|
|
8175
8213
|
};
|
|
@@ -11861,6 +11899,25 @@ var DashboardSocket = class {
|
|
|
11861
11899
|
});
|
|
11862
11900
|
});
|
|
11863
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
|
+
}
|
|
11864
11921
|
onSessionSteer(callback) {
|
|
11865
11922
|
this.io.on("connection", (socket) => {
|
|
11866
11923
|
socket.on("session:steer", (payload) => {
|
|
@@ -11902,6 +11959,122 @@ var DashboardSocket = class {
|
|
|
11902
11959
|
this.io.close();
|
|
11903
11960
|
}
|
|
11904
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
|
|
11905
12078
|
var __dirname$1 = path20.dirname(fileURLToPath(import.meta.url));
|
|
11906
12079
|
var DashboardServer = class {
|
|
11907
12080
|
app;
|
|
@@ -11927,6 +12100,14 @@ var DashboardServer = class {
|
|
|
11927
12100
|
* map is how that answer reaches the run that's blocked on it.
|
|
11928
12101
|
*/
|
|
11929
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;
|
|
11930
12111
|
port;
|
|
11931
12112
|
host;
|
|
11932
12113
|
workspacePath;
|
|
@@ -11944,6 +12125,7 @@ var DashboardServer = class {
|
|
|
11944
12125
|
secret: this.dashboardSecret,
|
|
11945
12126
|
corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
|
|
11946
12127
|
});
|
|
12128
|
+
this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
|
|
11947
12129
|
this.setupMiddleware();
|
|
11948
12130
|
this.setupRoutes();
|
|
11949
12131
|
this.socket.onSessionRate((sessionId, rating) => {
|
|
@@ -12007,6 +12189,9 @@ var DashboardServer = class {
|
|
|
12007
12189
|
cascade.on("peer:message", (e) => {
|
|
12008
12190
|
this.socket.emitPeerMessage(e);
|
|
12009
12191
|
});
|
|
12192
|
+
cascade.on("plan:approval-required", (e) => {
|
|
12193
|
+
this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
|
|
12194
|
+
});
|
|
12010
12195
|
try {
|
|
12011
12196
|
const result = await cascade.run({
|
|
12012
12197
|
prompt: runPrompt,
|
|
@@ -12014,7 +12199,8 @@ var DashboardServer = class {
|
|
|
12014
12199
|
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12015
12200
|
});
|
|
12016
12201
|
this.recordSessionTask(sessionId, result.taskId);
|
|
12017
|
-
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
12202
|
+
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
12203
|
+
this.captureWhy(sessionId, cascade, result);
|
|
12018
12204
|
this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
|
|
12019
12205
|
this.socket.broadcast("cost:update", {
|
|
12020
12206
|
sessionId,
|
|
@@ -12024,6 +12210,7 @@ var DashboardServer = class {
|
|
|
12024
12210
|
this.throttledBroadcast("workspace");
|
|
12025
12211
|
} catch (err) {
|
|
12026
12212
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
12213
|
+
this.captureWhy(sessionId, cascade);
|
|
12027
12214
|
this.socket.emitToSocket(socketId, "session:error", {
|
|
12028
12215
|
sessionId,
|
|
12029
12216
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -12034,9 +12221,17 @@ var DashboardServer = class {
|
|
|
12034
12221
|
this.denyPendingApprovals(sessionId);
|
|
12035
12222
|
}
|
|
12036
12223
|
});
|
|
12224
|
+
this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
|
|
12225
|
+
this.activeSessions.get(sessionId)?.resolvePlanApproval(
|
|
12226
|
+
approved,
|
|
12227
|
+
note,
|
|
12228
|
+
editedPlan
|
|
12229
|
+
);
|
|
12230
|
+
});
|
|
12037
12231
|
this.socket.onSessionHalt((sessionId) => {
|
|
12038
12232
|
this.activeControllers.get(sessionId)?.abort();
|
|
12039
12233
|
this.denyPendingApprovals(sessionId);
|
|
12234
|
+
this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
|
|
12040
12235
|
});
|
|
12041
12236
|
this.socket.onApprovalResponse(({ requestId, approved, always }) => {
|
|
12042
12237
|
const pending = this.pendingApprovals.get(requestId);
|
|
@@ -12069,12 +12264,21 @@ var DashboardServer = class {
|
|
|
12069
12264
|
resolve();
|
|
12070
12265
|
});
|
|
12071
12266
|
});
|
|
12267
|
+
try {
|
|
12268
|
+
this.scheduler.start();
|
|
12269
|
+
} catch (err) {
|
|
12270
|
+
console.warn("[dashboard] failed to start task scheduler:", err);
|
|
12271
|
+
}
|
|
12072
12272
|
}
|
|
12073
12273
|
async stop() {
|
|
12074
12274
|
if (this.broadcastTimer) {
|
|
12075
12275
|
clearTimeout(this.broadcastTimer);
|
|
12076
12276
|
this.broadcastTimer = null;
|
|
12077
12277
|
}
|
|
12278
|
+
try {
|
|
12279
|
+
this.scheduler.stop();
|
|
12280
|
+
} catch {
|
|
12281
|
+
}
|
|
12078
12282
|
this.socket.close();
|
|
12079
12283
|
try {
|
|
12080
12284
|
this.globalStore?.close();
|
|
@@ -12088,6 +12292,16 @@ var DashboardServer = class {
|
|
|
12088
12292
|
getSocket() {
|
|
12089
12293
|
return this.socket;
|
|
12090
12294
|
}
|
|
12295
|
+
/**
|
|
12296
|
+
* Rebind the workspace tasks execute in — e.g. the desktop app's Code view
|
|
12297
|
+
* opening a different project folder — without tearing down the socket
|
|
12298
|
+
* server (which would drop the port/auth token/connection mid-session).
|
|
12299
|
+
* The next `cascade:run` picks this up immediately since `this.workspacePath`
|
|
12300
|
+
* is read live per-run (see onCascadeRun below).
|
|
12301
|
+
*/
|
|
12302
|
+
setWorkspacePath(workspacePath) {
|
|
12303
|
+
this.workspacePath = workspacePath;
|
|
12304
|
+
}
|
|
12091
12305
|
/**
|
|
12092
12306
|
* Write the in-memory config back to the workspace config file so mutations
|
|
12093
12307
|
* made over the socket (Settings → Save) persist across restarts. Best-effort:
|
|
@@ -12185,16 +12399,60 @@ var DashboardServer = class {
|
|
|
12185
12399
|
return title;
|
|
12186
12400
|
}
|
|
12187
12401
|
/** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
|
|
12188
|
-
persistRunEnd(sessionId, title, latestPrompt, reply, status) {
|
|
12402
|
+
persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
|
|
12189
12403
|
try {
|
|
12190
12404
|
if (reply && reply.trim()) {
|
|
12191
12405
|
this.store.addMessage({ id: randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
12192
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
|
+
}
|
|
12193
12421
|
} catch (err) {
|
|
12194
12422
|
console.warn("[dashboard] failed to persist run end:", err);
|
|
12195
12423
|
}
|
|
12196
12424
|
this.persistRuntimeRow(sessionId, title, status, latestPrompt);
|
|
12197
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
|
+
}
|
|
12198
12456
|
/**
|
|
12199
12457
|
* Route steering text into running Cascade instances. Targets the given
|
|
12200
12458
|
* session, or every active session when none is specified (the desktop
|
|
@@ -12224,6 +12482,52 @@ var DashboardServer = class {
|
|
|
12224
12482
|
this.pendingApprovals.set(request.id, { resolve, sessionId });
|
|
12225
12483
|
});
|
|
12226
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
|
+
}
|
|
12227
12531
|
/** Deny + clear any approvals still pending for a session (run end / abort). */
|
|
12228
12532
|
denyPendingApprovals(sessionId) {
|
|
12229
12533
|
for (const [id, pending] of this.pendingApprovals) {
|
|
@@ -12736,6 +13040,9 @@ ${prompt}`;
|
|
|
12736
13040
|
cascade.on("peer:message", (e) => {
|
|
12737
13041
|
this.socket.emitPeerMessage(e);
|
|
12738
13042
|
});
|
|
13043
|
+
cascade.on("plan:approval-required", (e) => {
|
|
13044
|
+
this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
|
|
13045
|
+
});
|
|
12739
13046
|
try {
|
|
12740
13047
|
const result = await cascade.run({
|
|
12741
13048
|
prompt: runPrompt,
|
|
@@ -12743,7 +13050,8 @@ ${prompt}`;
|
|
|
12743
13050
|
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12744
13051
|
});
|
|
12745
13052
|
this.recordSessionTask(sessionId, result.taskId);
|
|
12746
|
-
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
13053
|
+
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
13054
|
+
this.captureWhy(sessionId, cascade, result);
|
|
12747
13055
|
this.socket.broadcast("cost:update", {
|
|
12748
13056
|
sessionId,
|
|
12749
13057
|
totalTokens: result.usage.totalTokens,
|
|
@@ -12753,6 +13061,7 @@ ${prompt}`;
|
|
|
12753
13061
|
this.throttledBroadcast("workspace");
|
|
12754
13062
|
} catch (err) {
|
|
12755
13063
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
13064
|
+
this.captureWhy(sessionId, cascade);
|
|
12756
13065
|
this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
|
|
12757
13066
|
sessionId,
|
|
12758
13067
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -12774,6 +13083,168 @@ ${prompt}`;
|
|
|
12774
13083
|
}))
|
|
12775
13084
|
});
|
|
12776
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
|
+
});
|
|
12777
13248
|
const prodPath = path20.resolve(__dirname$1, "../web/dist");
|
|
12778
13249
|
const devPath = path20.resolve(__dirname$1, "../../web/dist");
|
|
12779
13250
|
const webDistPath = fs19.existsSync(prodPath) ? prodPath : devPath;
|
|
@@ -12795,68 +13266,6 @@ ${prompt}`;
|
|
|
12795
13266
|
}
|
|
12796
13267
|
}
|
|
12797
13268
|
};
|
|
12798
|
-
var TaskScheduler = class {
|
|
12799
|
-
cronJobs = /* @__PURE__ */ new Map();
|
|
12800
|
-
store;
|
|
12801
|
-
runner;
|
|
12802
|
-
constructor(store, runner) {
|
|
12803
|
-
this.store = store;
|
|
12804
|
-
this.runner = runner;
|
|
12805
|
-
}
|
|
12806
|
-
start() {
|
|
12807
|
-
const tasks = this.store.listScheduledTasks();
|
|
12808
|
-
for (const task of tasks) {
|
|
12809
|
-
if (task.enabled) this.schedule(task);
|
|
12810
|
-
}
|
|
12811
|
-
}
|
|
12812
|
-
stop() {
|
|
12813
|
-
for (const job of this.cronJobs.values()) job.stop();
|
|
12814
|
-
this.cronJobs.clear();
|
|
12815
|
-
}
|
|
12816
|
-
schedule(task) {
|
|
12817
|
-
if (!cron.validate(task.cronExpression)) {
|
|
12818
|
-
throw new Error(`Invalid cron expression: ${task.cronExpression}`);
|
|
12819
|
-
}
|
|
12820
|
-
const existing = this.cronJobs.get(task.id);
|
|
12821
|
-
if (existing) {
|
|
12822
|
-
try {
|
|
12823
|
-
existing.stop();
|
|
12824
|
-
} catch {
|
|
12825
|
-
}
|
|
12826
|
-
}
|
|
12827
|
-
const job = cron.schedule(task.cronExpression, async () => {
|
|
12828
|
-
try {
|
|
12829
|
-
task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
|
|
12830
|
-
this.store.saveScheduledTask(task);
|
|
12831
|
-
await this.runner(task);
|
|
12832
|
-
} catch (err) {
|
|
12833
|
-
console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
|
|
12834
|
-
}
|
|
12835
|
-
}, { timezone: "UTC" });
|
|
12836
|
-
this.cronJobs.set(task.id, job);
|
|
12837
|
-
}
|
|
12838
|
-
unschedule(taskId) {
|
|
12839
|
-
this.cronJobs.get(taskId)?.stop();
|
|
12840
|
-
this.cronJobs.delete(taskId);
|
|
12841
|
-
}
|
|
12842
|
-
add(task) {
|
|
12843
|
-
this.store.saveScheduledTask(task);
|
|
12844
|
-
if (task.enabled) this.schedule(task);
|
|
12845
|
-
}
|
|
12846
|
-
remove(taskId) {
|
|
12847
|
-
this.unschedule(taskId);
|
|
12848
|
-
this.store.deleteScheduledTask(taskId);
|
|
12849
|
-
}
|
|
12850
|
-
list() {
|
|
12851
|
-
return this.store.listScheduledTasks();
|
|
12852
|
-
}
|
|
12853
|
-
isRunning(taskId) {
|
|
12854
|
-
return this.cronJobs.has(taskId);
|
|
12855
|
-
}
|
|
12856
|
-
static validateCron(expression) {
|
|
12857
|
-
return cron.validate(expression);
|
|
12858
|
-
}
|
|
12859
|
-
};
|
|
12860
13269
|
var execFileAsync2 = promisify(execFile);
|
|
12861
13270
|
var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
12862
13271
|
function sanitizeEnvValue(v) {
|