@alashi/cli 0.9.0 → 0.10.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.mjs +52 -4
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +230 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{store-Cxxnjq70.mjs → store-VoJe_U1Q.mjs} +736 -29
- package/dist/store-VoJe_U1Q.mjs.map +1 -0
- package/package.json +1 -1
- package/dist/store-Cxxnjq70.mjs.map +0 -1
|
@@ -2,8 +2,8 @@ import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION,
|
|
|
2
2
|
import semver from "semver";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
-
import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { z } from "zod";
|
|
@@ -11,6 +11,208 @@ import { Hono } from "hono";
|
|
|
11
11
|
import { streamSSE } from "hono/streaming";
|
|
12
12
|
import { Cron } from "croner";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
+
//#region src/usage.ts
|
|
15
|
+
/** Days of history kept in usage.json; older days are dropped on write. */
|
|
16
|
+
const USAGE_RETENTION_DAYS = 90;
|
|
17
|
+
/** Cents-of-a-millicent precision — enough for per-turn costs, short enough to keep JSON tidy. */
|
|
18
|
+
const COST_PRECISION = 6;
|
|
19
|
+
const DAY_MS = 864e5;
|
|
20
|
+
/** The UTC calendar day (`YYYY-MM-DD`) a timestamp falls in. */
|
|
21
|
+
function utcDay(at = /* @__PURE__ */ new Date()) {
|
|
22
|
+
return at.toISOString().slice(0, 10);
|
|
23
|
+
}
|
|
24
|
+
function round(value) {
|
|
25
|
+
return Number(value.toFixed(COST_PRECISION));
|
|
26
|
+
}
|
|
27
|
+
/** Money for humans: sub-dollar spend needs the extra digits, larger sums don't. */
|
|
28
|
+
function formatUsd(value) {
|
|
29
|
+
return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Per-app, per-UTC-day AI spend, persisted as OS bookkeeping in the app
|
|
33
|
+
* container (`apps/<name>/usage.json`, next to jobs.json and chat.json).
|
|
34
|
+
*
|
|
35
|
+
* Cost arrives from the `result` event's `costUsd`, which the SDK contract
|
|
36
|
+
* marks optional: engines that do not report cost simply contribute nothing.
|
|
37
|
+
* Writes are serialized per app so concurrent turns cannot lose an update,
|
|
38
|
+
* and each app has its own queue so a slow write for one app never delays
|
|
39
|
+
* another.
|
|
40
|
+
*/
|
|
41
|
+
var UsageRecorder = class {
|
|
42
|
+
store;
|
|
43
|
+
retentionDays;
|
|
44
|
+
/** Serializes usage.json read-modify-write per app. */
|
|
45
|
+
queues = /* @__PURE__ */ new Map();
|
|
46
|
+
constructor(store, retentionDays = 90) {
|
|
47
|
+
this.store = store;
|
|
48
|
+
this.retentionDays = retentionDays;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Adds one turn's cost to the app's total for the UTC day of `at`.
|
|
52
|
+
* A missing, non-finite, or negative `costUsd` is ignored (no turn counted,
|
|
53
|
+
* no file written) so a provider that omits cost cannot corrupt the totals.
|
|
54
|
+
*/
|
|
55
|
+
record(appName, costUsd, at = /* @__PURE__ */ new Date()) {
|
|
56
|
+
if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0) return Promise.resolve();
|
|
57
|
+
return this.enqueue(appName, async () => {
|
|
58
|
+
const state = await this.store.readUsageState(appName);
|
|
59
|
+
const date = utcDay(at);
|
|
60
|
+
const day = state.days.find((entry) => entry.date === date);
|
|
61
|
+
if (day) {
|
|
62
|
+
day.costUsd = round(day.costUsd + costUsd);
|
|
63
|
+
day.turns += 1;
|
|
64
|
+
} else state.days.push({
|
|
65
|
+
date,
|
|
66
|
+
costUsd: round(costUsd),
|
|
67
|
+
turns: 1
|
|
68
|
+
});
|
|
69
|
+
await this.store.writeUsageState(appName, this.prune(state, at));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** The app's recorded days, oldest first, with expired days filtered out. */
|
|
73
|
+
async read(appName, at = /* @__PURE__ */ new Date()) {
|
|
74
|
+
return this.prune(await this.store.readUsageState(appName), at);
|
|
75
|
+
}
|
|
76
|
+
/** Today's spend plus the daily series, for the dashboard and the usage endpoint. */
|
|
77
|
+
async summary(appName, at = /* @__PURE__ */ new Date()) {
|
|
78
|
+
const { days } = await this.read(appName, at);
|
|
79
|
+
const date = utcDay(at);
|
|
80
|
+
const today = days.find((entry) => entry.date === date);
|
|
81
|
+
return {
|
|
82
|
+
date,
|
|
83
|
+
todayCostUsd: today?.costUsd ?? 0,
|
|
84
|
+
todayTurns: today?.turns ?? 0,
|
|
85
|
+
totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
|
|
86
|
+
retentionDays: this.retentionDays,
|
|
87
|
+
days
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Drops days outside the retention window and normalizes ordering/values. */
|
|
91
|
+
prune(state, at) {
|
|
92
|
+
const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
|
|
93
|
+
return { days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => ({
|
|
94
|
+
date: entry.date,
|
|
95
|
+
costUsd: round(entry.costUsd),
|
|
96
|
+
turns: Number.isFinite(entry.turns) ? entry.turns : 0
|
|
97
|
+
})).sort((a, b) => a.date.localeCompare(b.date)) };
|
|
98
|
+
}
|
|
99
|
+
enqueue(appName, work) {
|
|
100
|
+
const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
|
|
101
|
+
const settled = next.catch(() => void 0);
|
|
102
|
+
this.queues.set(appName, settled);
|
|
103
|
+
settled.then(() => {
|
|
104
|
+
if (this.queues.get(appName) === settled) this.queues.delete(appName);
|
|
105
|
+
});
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/budget.ts
|
|
111
|
+
/**
|
|
112
|
+
* Stable discriminator on the thrown error. Apps see the host's error object
|
|
113
|
+
* across a package boundary (and, once `AppContext` becomes RPC, across a
|
|
114
|
+
* process boundary), so the reliable check is this code — not `instanceof`.
|
|
115
|
+
*/
|
|
116
|
+
const BUDGET_EXCEEDED_CODE = "budget_exceeded";
|
|
117
|
+
/**
|
|
118
|
+
* Refusal from the `createSession` budget gate.
|
|
119
|
+
*
|
|
120
|
+
* Extends the SDK's `AppSdkError` so it stays inside the taxonomy apps already
|
|
121
|
+
* catch, and carries a `code` plus the numbers behind the decision so an app
|
|
122
|
+
* can degrade gracefully (queue the work, skip the run, tell the user) instead
|
|
123
|
+
* of treating a spend cap as a crash. The message is written to stand alone:
|
|
124
|
+
* it is often the only text a human sees, rendered somewhere far from the host.
|
|
125
|
+
*/
|
|
126
|
+
var BudgetExceededError = class extends AppSdkError {
|
|
127
|
+
code = BUDGET_EXCEEDED_CODE;
|
|
128
|
+
appName;
|
|
129
|
+
/** UTC day the cap applies to. */
|
|
130
|
+
date;
|
|
131
|
+
/** The configured cap in USD. */
|
|
132
|
+
limitUsd;
|
|
133
|
+
/** Spend already recorded for `date`. */
|
|
134
|
+
spentUsd;
|
|
135
|
+
constructor(appName, params) {
|
|
136
|
+
super(`Daily AI spend cap reached: app "${appName}" has spent ${formatUsd(params.spentUsd)} of its ${formatUsd(params.limitUsd)} cap for ${params.date} (UTC). New sessions are refused until the cap resets at 00:00 UTC, or until it is raised in the app's alashi settings.`);
|
|
137
|
+
this.name = "BudgetExceededError";
|
|
138
|
+
this.appName = appName;
|
|
139
|
+
this.date = params.date;
|
|
140
|
+
this.limitUsd = params.limitUsd;
|
|
141
|
+
this.spentUsd = params.spentUsd;
|
|
142
|
+
}
|
|
143
|
+
/** Duck-typed check that survives duplicate copies of this module. */
|
|
144
|
+
static is(err) {
|
|
145
|
+
return err instanceof Error && err.code === "budget_exceeded";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* The cap the *host* has set for an app, or undefined when there is none.
|
|
150
|
+
*
|
|
151
|
+
* Read only from `GlobalConfig.budgets`, which is deliberately not part of the
|
|
152
|
+
* config layers merged into `ctx.config`: an app must not be able to raise its
|
|
153
|
+
* own ceiling through its manifest defaults or `apps/<name>/config.json`.
|
|
154
|
+
*
|
|
155
|
+
* A hand-edited cap that is not a finite number >= 0 is ignored rather than
|
|
156
|
+
* treated as zero — a typo in config.json should not brick an app, and the
|
|
157
|
+
* dashboard/settings write path validates before it ever gets stored.
|
|
158
|
+
*/
|
|
159
|
+
function appBudget(config, name) {
|
|
160
|
+
const dailyUsd = config.budgets?.[name]?.dailyUsd;
|
|
161
|
+
if (typeof dailyUsd !== "number" || !Number.isFinite(dailyUsd) || dailyUsd < 0) return void 0;
|
|
162
|
+
return dailyUsd;
|
|
163
|
+
}
|
|
164
|
+
/** Whether `spentUsd` has reached `dailyUsd`; the cap itself is inclusive. */
|
|
165
|
+
function isOver(spentUsd, dailyUsd) {
|
|
166
|
+
return dailyUsd !== void 0 && spentUsd >= dailyUsd;
|
|
167
|
+
}
|
|
168
|
+
/** Combines an already-read usage summary with a cap; no I/O. */
|
|
169
|
+
function budgetStatusFor(summary, dailyUsd) {
|
|
170
|
+
return {
|
|
171
|
+
date: summary.date,
|
|
172
|
+
spentUsd: summary.todayCostUsd,
|
|
173
|
+
...dailyUsd !== void 0 && {
|
|
174
|
+
dailyUsd,
|
|
175
|
+
remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
|
|
176
|
+
},
|
|
177
|
+
overBudget: isOver(summary.todayCostUsd, dailyUsd)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Today's spend against the app's cap, for the gate, the dashboard, and settings. */
|
|
181
|
+
async function budgetStatus(store, usage, name, at = /* @__PURE__ */ new Date()) {
|
|
182
|
+
const [config, summary] = await Promise.all([store.readGlobalConfig(), usage.summary(name, at)]);
|
|
183
|
+
return budgetStatusFor(summary, appBudget(config, name));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Fail-closed gate: throws `BudgetExceededError` when the app has already
|
|
187
|
+
* spent its daily cap. Evaluated fresh on every call, so a cap edit or a
|
|
188
|
+
* mid-day overspend takes effect without restarting the app.
|
|
189
|
+
*/
|
|
190
|
+
async function assertWithinBudget(store, usage, name, at = /* @__PURE__ */ new Date()) {
|
|
191
|
+
const status = await budgetStatus(store, usage, name, at);
|
|
192
|
+
if (!status.overBudget || status.dailyUsd === void 0) return;
|
|
193
|
+
throw new BudgetExceededError(name, {
|
|
194
|
+
date: status.date,
|
|
195
|
+
limitUsd: status.dailyUsd,
|
|
196
|
+
spentUsd: status.spentUsd
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Sets (or, with `undefined`, clears) an app's daily cap in the host config.
|
|
201
|
+
* Rejects anything that is not a finite, non-negative dollar amount; `0` is a
|
|
202
|
+
* legitimate value meaning "no AI spend at all for this app".
|
|
203
|
+
*/
|
|
204
|
+
async function setAppBudget(store, name, dailyUsd) {
|
|
205
|
+
if (dailyUsd !== void 0 && (!Number.isFinite(dailyUsd) || dailyUsd < 0)) throw new AppSdkError(`Invalid daily spend cap: ${JSON.stringify(dailyUsd)}`);
|
|
206
|
+
const config = await store.readGlobalConfig();
|
|
207
|
+
const budgets = { ...config.budgets };
|
|
208
|
+
if (dailyUsd === void 0) delete budgets[name];
|
|
209
|
+
else budgets[name] = { dailyUsd };
|
|
210
|
+
const next = { ...config };
|
|
211
|
+
if (Object.keys(budgets).length === 0) delete next.budgets;
|
|
212
|
+
else next.budgets = budgets;
|
|
213
|
+
await store.writeGlobalConfig(next);
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
14
216
|
//#region src/compat.ts
|
|
15
217
|
/**
|
|
16
218
|
* Enforces the manifest's declared `sdkVersion` range against the SDK
|
|
@@ -60,6 +262,11 @@ async function loadApp(repoDir) {
|
|
|
60
262
|
}
|
|
61
263
|
const definition = mod.default;
|
|
62
264
|
if (typeof definition !== "object" || definition === null || !("manifest" in definition)) throw new AppSdkError("App entry must default-export an app definition (use defineApp() from @alashi/apps-sdk)");
|
|
265
|
+
const raw = definition.manifest;
|
|
266
|
+
if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
|
|
267
|
+
name: typeof raw.name === "string" ? raw.name : entry,
|
|
268
|
+
sdkVersion: raw.sdkVersion
|
|
269
|
+
});
|
|
63
270
|
return {
|
|
64
271
|
definition,
|
|
65
272
|
manifest: parseManifest(definition.manifest),
|
|
@@ -68,7 +275,7 @@ async function loadApp(repoDir) {
|
|
|
68
275
|
}
|
|
69
276
|
//#endregion
|
|
70
277
|
//#region src/sources.ts
|
|
71
|
-
const execFileAsync$
|
|
278
|
+
const execFileAsync$2 = promisify(execFile);
|
|
72
279
|
function parseSourceArg(arg) {
|
|
73
280
|
if (arg.startsWith(".") || isAbsolute(arg)) return {
|
|
74
281
|
type: "local",
|
|
@@ -89,7 +296,7 @@ function parseSourceArg(arg) {
|
|
|
89
296
|
}
|
|
90
297
|
async function git(args, cwd) {
|
|
91
298
|
try {
|
|
92
|
-
const { stdout } = await execFileAsync$
|
|
299
|
+
const { stdout } = await execFileAsync$2("git", args, cwd ? { cwd } : {});
|
|
93
300
|
return stdout.trim();
|
|
94
301
|
} catch (err) {
|
|
95
302
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -220,7 +427,7 @@ async function resolveApp(store, appName) {
|
|
|
220
427
|
}
|
|
221
428
|
//#endregion
|
|
222
429
|
//#region src/installer.ts
|
|
223
|
-
const execFileAsync = promisify(execFile);
|
|
430
|
+
const execFileAsync$1 = promisify(execFile);
|
|
224
431
|
async function hasDependencies(dir) {
|
|
225
432
|
try {
|
|
226
433
|
const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
@@ -233,7 +440,7 @@ async function hasDependencies(dir) {
|
|
|
233
440
|
async function installDependencies(dir) {
|
|
234
441
|
if (!await hasDependencies(dir)) return;
|
|
235
442
|
try {
|
|
236
|
-
await execFileAsync("npm", [
|
|
443
|
+
await execFileAsync$1("npm", [
|
|
237
444
|
"install",
|
|
238
445
|
"--ignore-scripts",
|
|
239
446
|
"--omit=dev",
|
|
@@ -275,7 +482,6 @@ async function installApp(store, source, options = {}) {
|
|
|
275
482
|
}
|
|
276
483
|
try {
|
|
277
484
|
const { manifest } = await loadApp(inspectDir);
|
|
278
|
-
checkSdkCompatibility(manifest);
|
|
279
485
|
if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
|
|
280
486
|
if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
|
|
281
487
|
const repoDir = store.appRepoDir(manifest.name);
|
|
@@ -310,7 +516,6 @@ async function updateApp(store, name) {
|
|
|
310
516
|
await updateSource(record.source, repoDir);
|
|
311
517
|
await installDependencies(repoDir);
|
|
312
518
|
const { manifest } = await loadApp(repoDir);
|
|
313
|
-
checkSdkCompatibility(manifest);
|
|
314
519
|
if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
|
|
315
520
|
const updated = {
|
|
316
521
|
...record,
|
|
@@ -337,6 +542,276 @@ async function removeApp(store, name, options = {}) {
|
|
|
337
542
|
await rm(store.appRecordPath(name), { force: true });
|
|
338
543
|
}
|
|
339
544
|
//#endregion
|
|
545
|
+
//#region src/portability.ts
|
|
546
|
+
const execFileAsync = promisify(execFile);
|
|
547
|
+
/**
|
|
548
|
+
* Container portability. An app is only half-portable on its own: the
|
|
549
|
+
* container holds sessionId pointers, but the conversation transcripts live
|
|
550
|
+
* with the engine (keyed by the app's data dir as cwd). An export therefore
|
|
551
|
+
* snapshots the container *plus* the transcripts, engine-neutrally, and the
|
|
552
|
+
* import re-keys them to this machine's data dir.
|
|
553
|
+
*
|
|
554
|
+
* Archive layout (tar.gz):
|
|
555
|
+
* alashi-export.json { formatVersion, exportedAt, app, transcripts }
|
|
556
|
+
* container/ the contents of $ALASHI_HOME/apps/<name>/
|
|
557
|
+
* transcripts/<sessionId>.jsonl
|
|
558
|
+
*/
|
|
559
|
+
const EXPORT_FORMAT_VERSION = 1;
|
|
560
|
+
const METADATA_FILE = "alashi-export.json";
|
|
561
|
+
const CONTAINER_DIR = "container";
|
|
562
|
+
const TRANSCRIPTS_DIR = "transcripts";
|
|
563
|
+
const TRANSCRIPT_EXT = ".jsonl";
|
|
564
|
+
/** Re-derivable from the source, and by far the biggest thing in a container. */
|
|
565
|
+
const CONTAINER_EXCLUDES = [join("repo", "node_modules"), join("repo", ".git")];
|
|
566
|
+
/** Session ids become file names, so they must not be able to escape the dir. */
|
|
567
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
568
|
+
/** Shelling out to tar matches how git and npm are already invoked. */
|
|
569
|
+
async function tar(args) {
|
|
570
|
+
try {
|
|
571
|
+
const { stdout } = await execFileAsync("tar", args);
|
|
572
|
+
return stdout;
|
|
573
|
+
} catch (err) {
|
|
574
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
575
|
+
throw new AppSdkError(`tar ${args[0]} failed: ${message}`, { cause: err });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function exists(path) {
|
|
579
|
+
try {
|
|
580
|
+
await lstat(path);
|
|
581
|
+
return true;
|
|
582
|
+
} catch (err) {
|
|
583
|
+
if (err.code === "ENOENT") return false;
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function isDirectory(path) {
|
|
588
|
+
try {
|
|
589
|
+
return (await stat(path)).isDirectory();
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function containerFilter(appDir) {
|
|
595
|
+
return (src) => {
|
|
596
|
+
const rel = relative(appDir, src);
|
|
597
|
+
if (rel === "") return true;
|
|
598
|
+
return !CONTAINER_EXCLUDES.some((excluded) => rel === excluded || rel.startsWith(excluded + sep));
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Packs apps/<name>/ plus the transcripts of every conversation that ever had
|
|
603
|
+
* a turn into a single self-contained tar.gz.
|
|
604
|
+
*/
|
|
605
|
+
async function exportApp(store, provider, name, options = {}) {
|
|
606
|
+
const record = await store.readAppRecord(name);
|
|
607
|
+
if (!record) throw new AppSdkError(`App "${name}" is not installed`);
|
|
608
|
+
const archivePath = resolve(process.cwd(), options.out ?? `${record.name}-${record.version}.tar.gz`);
|
|
609
|
+
if (await exists(archivePath)) throw new AppSdkError(`Refusing to overwrite existing file: ${archivePath}`);
|
|
610
|
+
const warnings = [];
|
|
611
|
+
const appDir = store.appDir(name);
|
|
612
|
+
const stage = join(store.stagingDir, randomUUID());
|
|
613
|
+
try {
|
|
614
|
+
await mkdir(stage, { recursive: true });
|
|
615
|
+
await cp(appDir, join(stage, CONTAINER_DIR), {
|
|
616
|
+
recursive: true,
|
|
617
|
+
dereference: true,
|
|
618
|
+
filter: containerFilter(appDir)
|
|
619
|
+
});
|
|
620
|
+
const { conversations } = await store.readChatState(name);
|
|
621
|
+
const sessionIds = [...new Set(conversations.map((conversation) => conversation.sessionId).filter((sessionId) => !!sessionId))];
|
|
622
|
+
let transcripts = [];
|
|
623
|
+
if (!provider.exportTranscripts) {
|
|
624
|
+
if (sessionIds.length > 0) warnings.push(`Provider "${provider.name}" cannot export transcripts; the archive holds the container only`);
|
|
625
|
+
} else if (sessionIds.length > 0) transcripts = await provider.exportTranscripts(sessionIds);
|
|
626
|
+
await mkdir(join(stage, TRANSCRIPTS_DIR), { recursive: true });
|
|
627
|
+
const written = [];
|
|
628
|
+
for (const transcript of transcripts) {
|
|
629
|
+
if (!SESSION_ID_PATTERN.test(transcript.sessionId)) {
|
|
630
|
+
warnings.push(`Skipped transcript with unusable session id ${JSON.stringify(transcript.sessionId)}`);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
await writeFile(join(stage, TRANSCRIPTS_DIR, `${transcript.sessionId}${TRANSCRIPT_EXT}`), transcript.content, "utf8");
|
|
634
|
+
written.push(transcript.sessionId);
|
|
635
|
+
}
|
|
636
|
+
if (provider.exportTranscripts && written.length < sessionIds.length) warnings.push(`${sessionIds.length - written.length} of ${sessionIds.length} conversations had no transcript in "${provider.name}" storage`);
|
|
637
|
+
const metadata = {
|
|
638
|
+
formatVersion: 1,
|
|
639
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
640
|
+
app: record,
|
|
641
|
+
transcripts: written
|
|
642
|
+
};
|
|
643
|
+
await writeFile(join(stage, METADATA_FILE), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
644
|
+
await mkdir(dirname(archivePath), { recursive: true });
|
|
645
|
+
await tar([
|
|
646
|
+
"-czf",
|
|
647
|
+
archivePath,
|
|
648
|
+
"-C",
|
|
649
|
+
stage,
|
|
650
|
+
"."
|
|
651
|
+
]);
|
|
652
|
+
return {
|
|
653
|
+
archivePath,
|
|
654
|
+
record,
|
|
655
|
+
transcriptCount: written.length,
|
|
656
|
+
warnings
|
|
657
|
+
};
|
|
658
|
+
} finally {
|
|
659
|
+
await rm(stage, {
|
|
660
|
+
recursive: true,
|
|
661
|
+
force: true
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** An archive is untrusted input: no absolute members, no ".." traversal. */
|
|
666
|
+
async function assertSafeMembers(archive) {
|
|
667
|
+
const listing = await tar(["-tzf", archive]);
|
|
668
|
+
for (const line of listing.split("\n")) {
|
|
669
|
+
const member = line.trim();
|
|
670
|
+
if (member === "") continue;
|
|
671
|
+
const segments = member.split(/[\\/]/);
|
|
672
|
+
if (isAbsolute(member) || member.startsWith("/") || segments.includes("..")) throw new AppSdkError(`Refusing to import ${archive}: it contains an unsafe path ${JSON.stringify(member)}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Second belt: a symlink inside the archive must not point out of the stage. */
|
|
676
|
+
async function assertNothingEscaped(stage) {
|
|
677
|
+
const root = resolve(stage);
|
|
678
|
+
const walk = async (dir) => {
|
|
679
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
680
|
+
const path = join(dir, entry.name);
|
|
681
|
+
if (entry.isSymbolicLink()) {
|
|
682
|
+
const target = resolve(dir, await readlink(path));
|
|
683
|
+
const rel = relative(root, target);
|
|
684
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new AppSdkError(`Refusing to import: ${relative(root, path)} links outside the archive (${target})`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (entry.isDirectory()) await walk(path);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
await walk(root);
|
|
691
|
+
}
|
|
692
|
+
async function readMetadata(path) {
|
|
693
|
+
let raw;
|
|
694
|
+
try {
|
|
695
|
+
raw = await readFile(path, "utf8");
|
|
696
|
+
} catch (err) {
|
|
697
|
+
throw new AppSdkError(`Not an alashi export archive: ${METADATA_FILE} is missing`, { cause: err });
|
|
698
|
+
}
|
|
699
|
+
let parsed;
|
|
700
|
+
try {
|
|
701
|
+
parsed = JSON.parse(raw);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
throw new AppSdkError(`Corrupt ${METADATA_FILE} in archive`, { cause: err });
|
|
704
|
+
}
|
|
705
|
+
const metadata = parsed;
|
|
706
|
+
if (metadata?.formatVersion !== 1) throw new AppSdkError(`Unsupported export formatVersion ${JSON.stringify(metadata?.formatVersion)}; this alashi reads formatVersion 1`);
|
|
707
|
+
return metadata;
|
|
708
|
+
}
|
|
709
|
+
async function readInstallRecord(path) {
|
|
710
|
+
let parsed;
|
|
711
|
+
try {
|
|
712
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
713
|
+
} catch (err) {
|
|
714
|
+
throw new AppSdkError(`Archive has no readable ${CONTAINER_DIR}/app.json`, { cause: err });
|
|
715
|
+
}
|
|
716
|
+
const record = parsed;
|
|
717
|
+
if (!record || typeof record.name !== "string" || typeof record.version !== "string") throw new AppSdkError(`Archive ${CONTAINER_DIR}/app.json is not an install record`);
|
|
718
|
+
if (!APP_NAME_PATTERN.test(record.name)) throw new AppSdkError(`Archive carries an invalid app name: ${JSON.stringify(record.name)}`);
|
|
719
|
+
return record;
|
|
720
|
+
}
|
|
721
|
+
async function readTranscripts(dir) {
|
|
722
|
+
let entries;
|
|
723
|
+
try {
|
|
724
|
+
entries = await readdir(dir);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
if (err.code === "ENOENT") return [];
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
729
|
+
const transcripts = [];
|
|
730
|
+
for (const entry of entries.sort()) {
|
|
731
|
+
if (!entry.endsWith(TRANSCRIPT_EXT)) continue;
|
|
732
|
+
const sessionId = entry.slice(0, -6);
|
|
733
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) continue;
|
|
734
|
+
transcripts.push({
|
|
735
|
+
sessionId,
|
|
736
|
+
content: await readFile(join(dir, entry), "utf8")
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
return transcripts;
|
|
740
|
+
}
|
|
741
|
+
/** Staging and apps/ are both under $ALASHI_HOME, so rename is the fast path. */
|
|
742
|
+
async function moveDir(from, to) {
|
|
743
|
+
try {
|
|
744
|
+
await rename(from, to);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (err.code !== "EXDEV") throw err;
|
|
747
|
+
await cp(from, to, { recursive: true });
|
|
748
|
+
await rm(from, {
|
|
749
|
+
recursive: true,
|
|
750
|
+
force: true
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Restores a container exported by {@link exportApp}, plus its transcripts. */
|
|
755
|
+
async function importApp(store, provider, archivePath) {
|
|
756
|
+
const archive = resolve(process.cwd(), archivePath);
|
|
757
|
+
if (!await exists(archive)) throw new AppSdkError(`Archive not found: ${archive}`);
|
|
758
|
+
await assertSafeMembers(archive);
|
|
759
|
+
const warnings = [];
|
|
760
|
+
const stage = join(store.stagingDir, randomUUID());
|
|
761
|
+
try {
|
|
762
|
+
await mkdir(stage, { recursive: true });
|
|
763
|
+
await tar([
|
|
764
|
+
"-xzf",
|
|
765
|
+
archive,
|
|
766
|
+
"-C",
|
|
767
|
+
stage,
|
|
768
|
+
"--no-same-owner"
|
|
769
|
+
]);
|
|
770
|
+
await assertNothingEscaped(stage);
|
|
771
|
+
await readMetadata(join(stage, METADATA_FILE));
|
|
772
|
+
const containerDir = join(stage, CONTAINER_DIR);
|
|
773
|
+
if (!await isDirectory(containerDir)) throw new AppSdkError(`Archive has no ${CONTAINER_DIR}/ directory: ${archive}`);
|
|
774
|
+
const record = await readInstallRecord(join(containerDir, "app.json"));
|
|
775
|
+
const name = record.name;
|
|
776
|
+
if (await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is already installed (use: alashi remove ${name})`);
|
|
777
|
+
const appDir = store.appDir(name);
|
|
778
|
+
if (await exists(appDir)) throw new AppSdkError(`${appDir} already exists (leftover data from a previous install); delete it before importing`);
|
|
779
|
+
await mkdir(store.appsDir, { recursive: true });
|
|
780
|
+
await moveDir(containerDir, appDir);
|
|
781
|
+
try {
|
|
782
|
+
await mkdir(store.appDataDir(name), { recursive: true });
|
|
783
|
+
await installDependencies(store.appRepoDir(name));
|
|
784
|
+
} catch (err) {
|
|
785
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
786
|
+
throw new AppSdkError(`Restored ${name} to ${appDir}, but reinstalling its dependencies failed: ${message}. Run "alashi update ${name}" before using it.`, { cause: err });
|
|
787
|
+
}
|
|
788
|
+
const transcripts = await readTranscripts(join(stage, TRANSCRIPTS_DIR));
|
|
789
|
+
let restored = 0;
|
|
790
|
+
if (transcripts.length > 0) {
|
|
791
|
+
if (!provider.importTranscripts) warnings.push(`Provider "${provider.name}" cannot import transcripts; ${transcripts.length} conversation transcripts were not restored`);
|
|
792
|
+
else {
|
|
793
|
+
try {
|
|
794
|
+
await provider.importTranscripts(transcripts, { cwd: store.appDataDir(name) });
|
|
795
|
+
} catch (err) {
|
|
796
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
797
|
+
throw new AppSdkError(`Restored ${name} to ${appDir}, but restoring its transcripts failed: ${message}. The app works; its chat history does not.`, { cause: err });
|
|
798
|
+
}
|
|
799
|
+
restored = transcripts.length;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
record,
|
|
804
|
+
transcriptCount: restored,
|
|
805
|
+
warnings
|
|
806
|
+
};
|
|
807
|
+
} finally {
|
|
808
|
+
await rm(stage, {
|
|
809
|
+
recursive: true,
|
|
810
|
+
force: true
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
//#endregion
|
|
340
815
|
//#region src/runtime.ts
|
|
341
816
|
function createLogger(prefix) {
|
|
342
817
|
const log = (level, message, data) => {
|
|
@@ -352,6 +827,30 @@ function createLogger(prefix) {
|
|
|
352
827
|
};
|
|
353
828
|
}
|
|
354
829
|
/**
|
|
830
|
+
* Wraps a session so every `result` event's cost is accounted for before the
|
|
831
|
+
* event reaches the caller. This sits under *every* session an app opens —
|
|
832
|
+
* chat turns, cron job handlers, and app routes alike — so spend is recorded
|
|
833
|
+
* no matter who opened the session.
|
|
834
|
+
*/
|
|
835
|
+
function meterSession(session, recordCost) {
|
|
836
|
+
return {
|
|
837
|
+
get id() {
|
|
838
|
+
return session.id;
|
|
839
|
+
},
|
|
840
|
+
send(input) {
|
|
841
|
+
const events = session.send(input);
|
|
842
|
+
return (async function* metered() {
|
|
843
|
+
for await (const event of events) {
|
|
844
|
+
if (event.type === "result") await recordCost(event.costUsd);
|
|
845
|
+
yield event;
|
|
846
|
+
}
|
|
847
|
+
})();
|
|
848
|
+
},
|
|
849
|
+
interrupt: () => session.interrupt(),
|
|
850
|
+
close: () => session.close()
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
355
854
|
* Wraps the host provider so every session an app opens automatically gets
|
|
356
855
|
* its manifest-declared MCP servers, tool allowlist, agents, skills,
|
|
357
856
|
* instructions, and data-dir cwd. Per-call options still win where they
|
|
@@ -362,6 +861,7 @@ function scopeProvider(provider, defaults) {
|
|
|
362
861
|
name: provider.name,
|
|
363
862
|
async createSession(options = {}) {
|
|
364
863
|
if (defaults.unreadyError !== void 0) throw new ConfigError(defaults.unreadyError);
|
|
864
|
+
await defaults.checkBudget?.();
|
|
365
865
|
const mcpServers = {
|
|
366
866
|
...defaults.mcpServers,
|
|
367
867
|
...options.mcpServers
|
|
@@ -372,7 +872,7 @@ function scopeProvider(provider, defaults) {
|
|
|
372
872
|
...options.agents
|
|
373
873
|
};
|
|
374
874
|
const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
|
|
375
|
-
|
|
875
|
+
const session = await provider.createSession({
|
|
376
876
|
cwd: defaults.cwd,
|
|
377
877
|
...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
|
|
378
878
|
...options,
|
|
@@ -381,6 +881,7 @@ function scopeProvider(provider, defaults) {
|
|
|
381
881
|
...Object.keys(agents).length > 0 && { agents },
|
|
382
882
|
...skillDirs.length > 0 && { skillDirs }
|
|
383
883
|
});
|
|
884
|
+
return defaults.recordCost ? meterSession(session, defaults.recordCost) : session;
|
|
384
885
|
}
|
|
385
886
|
};
|
|
386
887
|
}
|
|
@@ -420,8 +921,12 @@ async function resolveAppConfig(store, manifest) {
|
|
|
420
921
|
* Setup state shapes the session defaults: unconfigured MCP servers are
|
|
421
922
|
* dropped (with their tools) so interpolation never hits a missing key, and
|
|
422
923
|
* while required keys are missing the scoped provider fails closed.
|
|
924
|
+
*
|
|
925
|
+
* When a `usage` recorder is supplied, every session opened through the
|
|
926
|
+
* returned context reports its per-turn cost to it, and the app's host-set
|
|
927
|
+
* daily spend cap (if any) is enforced on every createSession.
|
|
423
928
|
*/
|
|
424
|
-
async function buildAppContext(store, manifest, provider) {
|
|
929
|
+
async function buildAppContext(store, manifest, provider, usage) {
|
|
425
930
|
const name = manifest.name;
|
|
426
931
|
const dataDir = store.appDataDir(name);
|
|
427
932
|
const repoDir = store.appRepoDir(name);
|
|
@@ -437,11 +942,19 @@ async function buildAppContext(store, manifest, provider) {
|
|
|
437
942
|
const excluded = status.integrations.filter((i) => !i.configured).map((i) => i.name);
|
|
438
943
|
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
439
944
|
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
945
|
+
const logger = createLogger(name);
|
|
946
|
+
const recordCost = usage ? async (costUsd) => {
|
|
947
|
+
try {
|
|
948
|
+
await usage.record(name, costUsd);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
logger.warn("Failed to record AI spend", { error: String(err) });
|
|
951
|
+
}
|
|
952
|
+
} : void 0;
|
|
440
953
|
return {
|
|
441
954
|
appName: name,
|
|
442
955
|
dataDir,
|
|
443
956
|
config,
|
|
444
|
-
logger
|
|
957
|
+
logger,
|
|
445
958
|
state: new JsonFileStore(join(dataDir, "state.json")),
|
|
446
959
|
ai: scopeProvider(provider, {
|
|
447
960
|
mcpServers,
|
|
@@ -450,6 +963,8 @@ async function buildAppContext(store, manifest, provider) {
|
|
|
450
963
|
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
451
964
|
...instructions !== void 0 && { instructions },
|
|
452
965
|
cwd: dataDir,
|
|
966
|
+
...recordCost !== void 0 && { recordCost },
|
|
967
|
+
...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
|
|
453
968
|
...!status.ready && { unreadyError: `App "${name}" is not configured: missing ${status.missing.join(", ")}. Set the missing keys in the dashboard settings or ${store.appConfigPath(name)}.` }
|
|
454
969
|
})
|
|
455
970
|
};
|
|
@@ -616,12 +1131,15 @@ function settingsPage(name) {
|
|
|
616
1131
|
.ro-item { margin-bottom: 12px; }
|
|
617
1132
|
.ro-item:last-child { margin-bottom: 0; }
|
|
618
1133
|
details summary { cursor: pointer; }
|
|
1134
|
+
#budget-card input { max-width: 180px; }
|
|
1135
|
+
#budget-status { margin-left: 12px; color: var(--muted); }
|
|
619
1136
|
</style>
|
|
620
1137
|
</head>
|
|
621
1138
|
<body>
|
|
622
1139
|
<header class="topbar"><a href="/">alashi</a> <span>/</span> <strong id="title"></strong> <span>settings</span></header>
|
|
623
1140
|
<main class="narrow">
|
|
624
1141
|
<div id="banner" class="banner" hidden></div>
|
|
1142
|
+
<div id="budget-card"></div>
|
|
625
1143
|
<div id="cards"></div>
|
|
626
1144
|
<button id="save" class="primary">Save changes</button><span id="status"></span>
|
|
627
1145
|
<div id="manifest-cards"></div>
|
|
@@ -754,6 +1272,78 @@ function render(data) {
|
|
|
754
1272
|
}
|
|
755
1273
|
}
|
|
756
1274
|
|
|
1275
|
+
function usd(value) {
|
|
1276
|
+
return '$' + value.toFixed(value >= 1 ? 2 : 4);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Daily spend cap. A host setting, not an app config key: it lives in the
|
|
1281
|
+
* host's own config.json and is enforced in createSession, so an app cannot
|
|
1282
|
+
* raise its own ceiling through its manifest defaults or its config layer.
|
|
1283
|
+
*/
|
|
1284
|
+
function renderBudget(b, message) {
|
|
1285
|
+
const root = document.getElementById('budget-card');
|
|
1286
|
+
root.replaceChildren();
|
|
1287
|
+
const capped = typeof b.dailyUsd === 'number';
|
|
1288
|
+
|
|
1289
|
+
const input = el('input', {
|
|
1290
|
+
type: 'number', min: '0', step: '0.01', placeholder: 'no cap',
|
|
1291
|
+
value: capped ? String(b.dailyUsd) : '',
|
|
1292
|
+
});
|
|
1293
|
+
const status = el('span', { id: 'budget-status', text: message || '' });
|
|
1294
|
+
|
|
1295
|
+
const save = el('button', {
|
|
1296
|
+
class: 'primary',
|
|
1297
|
+
text: 'Save cap',
|
|
1298
|
+
onclick: async () => {
|
|
1299
|
+
const raw = input.value.trim();
|
|
1300
|
+
const dailyUsd = raw === '' ? null : Number(raw);
|
|
1301
|
+
if (dailyUsd !== null && !(dailyUsd >= 0)) {
|
|
1302
|
+
status.textContent = 'Enter a dollar amount, or leave empty for no cap.';
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
status.textContent = 'Saving…';
|
|
1306
|
+
const res = await fetch('/api/apps/' + APP + '/budget', {
|
|
1307
|
+
method: 'PUT',
|
|
1308
|
+
headers: { 'content-type': 'application/json' },
|
|
1309
|
+
body: JSON.stringify({ dailyUsd }),
|
|
1310
|
+
});
|
|
1311
|
+
if (!res.ok) {
|
|
1312
|
+
status.textContent = 'Save failed (' + res.status + ')';
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
renderBudget(await res.json(), dailyUsd === null ? 'Cap removed.' : 'Cap saved.');
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
const spent = 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd);
|
|
1320
|
+
const body = [
|
|
1321
|
+
el('div', { class: 'desc', text:
|
|
1322
|
+
'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
|
|
1323
|
+
+ 'this app are refused, and the counter resets at 00:00 UTC. This is a host setting — the '
|
|
1324
|
+
+ 'app cannot read or raise it through its own config.' }),
|
|
1325
|
+
el('div', { class: 'field' }, [
|
|
1326
|
+
el('label', { text: 'Daily spend cap (USD)' }),
|
|
1327
|
+
input,
|
|
1328
|
+
]),
|
|
1329
|
+
el('div', { class: 'desc', text: capped
|
|
1330
|
+
? spent + ' — ' + usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
|
|
1331
|
+
: spent + ' — no cap set, spend is unlimited' }),
|
|
1332
|
+
el('div', { style: 'margin-top:12px' }, [save, status]),
|
|
1333
|
+
];
|
|
1334
|
+
if (b.overBudget) {
|
|
1335
|
+
body.unshift(el('div', { class: 'banner warn', text:
|
|
1336
|
+
'Over budget — new AI sessions are refused. ' + usd(b.spentUsd) + ' spent of the '
|
|
1337
|
+
+ usd(b.dailyUsd) + ' daily cap for ' + b.date + ' (UTC). Raise or clear the cap below, '
|
|
1338
|
+
+ 'or wait for the reset at 00:00 UTC.' }));
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
root.append(el('div', { class: 'card' }, [
|
|
1342
|
+
el('h2', { text: 'Spend cap' }),
|
|
1343
|
+
el('div', { class: 'body' }, body),
|
|
1344
|
+
]));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
757
1347
|
function roCard(title, children) {
|
|
758
1348
|
return el('div', { class: 'card' }, [
|
|
759
1349
|
el('h2', { text: title }),
|
|
@@ -827,10 +1417,13 @@ function renderManifest(m) {
|
|
|
827
1417
|
}
|
|
828
1418
|
|
|
829
1419
|
async function load() {
|
|
830
|
-
const [res, manRes] = await Promise.all([
|
|
1420
|
+
const [res, manRes, budgetRes] = await Promise.all([
|
|
831
1421
|
fetch('/api/apps/' + APP + '/config'),
|
|
832
1422
|
fetch('/api/apps/' + APP + '/manifest'),
|
|
1423
|
+
fetch('/api/apps/' + APP + '/budget'),
|
|
833
1424
|
]);
|
|
1425
|
+
// Host bookkeeping: the cap renders even when the app itself fails to load.
|
|
1426
|
+
if (budgetRes.ok) renderBudget(await budgetRes.json());
|
|
834
1427
|
if (!res.ok) {
|
|
835
1428
|
document.getElementById('banner').hidden = false;
|
|
836
1429
|
document.getElementById('banner').className = 'banner warn';
|
|
@@ -882,14 +1475,44 @@ const CONTENT_TYPES = {
|
|
|
882
1475
|
function escapeHtml(value) {
|
|
883
1476
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
884
1477
|
}
|
|
1478
|
+
/** Host-rendered failure page — apps never need to ship their own error UI. */
|
|
1479
|
+
function appErrorPage(name, err) {
|
|
1480
|
+
return `<!doctype html>
|
|
1481
|
+
<html lang="en">
|
|
1482
|
+
<head>
|
|
1483
|
+
<meta charset="utf-8">
|
|
1484
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1485
|
+
<title>${escapeHtml(name)} — failed to load</title>
|
|
1486
|
+
<link rel="stylesheet" href="/assets/v1/system.css">
|
|
1487
|
+
</head>
|
|
1488
|
+
<body>
|
|
1489
|
+
<header class="topbar"><strong>alashi</strong></header>
|
|
1490
|
+
<main class="narrow">
|
|
1491
|
+
<h1>${escapeHtml(name)} failed to load</h1>
|
|
1492
|
+
<div class="panel">
|
|
1493
|
+
<p>${escapeHtml(String(err))}</p>
|
|
1494
|
+
<p class="muted"><a href="/">Back to apps</a></p>
|
|
1495
|
+
</div>
|
|
1496
|
+
</main>
|
|
1497
|
+
</body>
|
|
1498
|
+
</html>
|
|
1499
|
+
`;
|
|
1500
|
+
}
|
|
885
1501
|
/**
|
|
886
1502
|
* The host HTTP surface:
|
|
887
1503
|
* GET / dashboard of installed apps
|
|
888
1504
|
* GET /settings/:name per-app settings page (config form)
|
|
889
|
-
* GET /api/apps installed apps as JSON
|
|
1505
|
+
* GET /api/apps installed apps as JSON, each with todayCostUsd
|
|
1506
|
+
* and its daily spend cap / over-budget state
|
|
1507
|
+
* GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
|
|
1508
|
+
* and the budget status
|
|
1509
|
+
* GET /api/apps/:name/budget today's spend against the app's daily cap
|
|
1510
|
+
* PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
|
|
1511
|
+
* (a host setting; apps cannot set their own)
|
|
890
1512
|
* POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
|
|
891
1513
|
* (omit conversationId to start a new conversation; the
|
|
892
|
-
* id is echoed in the x-conversation-id response header
|
|
1514
|
+
* id is echoed in the x-conversation-id response header;
|
|
1515
|
+
* 402 when the app is over its daily spend cap)
|
|
893
1516
|
* GET /api/apps/:name/chat/conversations conversations, most recent first
|
|
894
1517
|
* POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
|
|
895
1518
|
* optionally adopting an existing provider session (e.g. an
|
|
@@ -912,9 +1535,10 @@ function escapeHtml(value) {
|
|
|
912
1535
|
function createServer(store, provider) {
|
|
913
1536
|
const apps = /* @__PURE__ */ new Map();
|
|
914
1537
|
const sessions = /* @__PURE__ */ new Map();
|
|
1538
|
+
const usage = new UsageRecorder(store);
|
|
915
1539
|
async function startApp(name) {
|
|
916
1540
|
const { definition, manifest } = await loadApp(store.appRepoDir(name));
|
|
917
|
-
const ctx = await buildAppContext(store, manifest, provider);
|
|
1541
|
+
const ctx = await buildAppContext(store, manifest, provider, usage);
|
|
918
1542
|
return {
|
|
919
1543
|
manifest,
|
|
920
1544
|
ctx,
|
|
@@ -1021,35 +1645,46 @@ function createServer(store, provider) {
|
|
|
1021
1645
|
return c.json({ error: "Not found" }, 404);
|
|
1022
1646
|
}
|
|
1023
1647
|
});
|
|
1024
|
-
/** Readiness for the dashboard badge;
|
|
1648
|
+
/** Readiness for the dashboard badge; loadError carries the failure reason. */
|
|
1025
1649
|
async function readiness(name) {
|
|
1026
1650
|
try {
|
|
1027
1651
|
const running = await getApp(name);
|
|
1028
1652
|
if (!running) return void 0;
|
|
1029
1653
|
return (await resolveAppConfig(store, running.manifest)).status;
|
|
1030
|
-
} catch {
|
|
1031
|
-
return;
|
|
1654
|
+
} catch (err) {
|
|
1655
|
+
return { loadError: String(err) };
|
|
1032
1656
|
}
|
|
1033
1657
|
}
|
|
1034
1658
|
app.get("/", async (c) => {
|
|
1035
1659
|
const records = await store.listInstalled();
|
|
1036
|
-
const
|
|
1660
|
+
const cells = await Promise.all(records.map(async (r) => {
|
|
1037
1661
|
const name = escapeHtml(r.name);
|
|
1038
1662
|
const status = await readiness(r.name);
|
|
1039
|
-
const badge = !status ? "<span class=\"badge err\">failed to load</span>" : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
|
|
1040
|
-
|
|
1663
|
+
const badge = !status ? "<span class=\"badge err\">failed to load</span>" : "loadError" in status ? `<span class="badge err" title="${escapeHtml(status.loadError)}">failed to load</span>` : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
|
|
1664
|
+
const budget = await budgetStatus(store, usage, r.name);
|
|
1665
|
+
const todayCostUsd = budget.spentUsd;
|
|
1666
|
+
const overBadge = budget.overBudget ? ` <a href="/settings/${name}" title="Daily AI spend cap reached — new sessions are refused until 00:00 UTC"><span class="badge err">over budget</span></a>` : "";
|
|
1667
|
+
const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
|
|
1668
|
+
return {
|
|
1669
|
+
row: `<tr>
|
|
1041
1670
|
<td><a href="/app/${name}/">${name}</a></td>
|
|
1042
1671
|
<td class="muted">v${escapeHtml(r.version)}</td>
|
|
1043
1672
|
<td class="muted">${escapeHtml(r.source.type)}</td>
|
|
1044
|
-
<td>${badge}</td>
|
|
1673
|
+
<td>${badge}${overBadge}</td>
|
|
1674
|
+
<td class="muted" style="text-align:right">${formatUsd(todayCostUsd)}${cap}</td>
|
|
1045
1675
|
<td style="text-align:right"><a href="/settings/${name}">settings</a></td>
|
|
1046
|
-
</tr
|
|
1047
|
-
|
|
1676
|
+
</tr>`,
|
|
1677
|
+
todayCostUsd
|
|
1678
|
+
};
|
|
1679
|
+
}));
|
|
1680
|
+
const rows = cells.map((cell) => cell.row).join("\n");
|
|
1681
|
+
const totalToday = cells.reduce((sum, cell) => sum + cell.todayCostUsd, 0);
|
|
1048
1682
|
const body = records.length === 0 ? "<div class=\"empty\">No apps installed. Run <code>alashi install <name></code>.</div>" : `<table>
|
|
1049
|
-
<thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th></th></tr></thead>
|
|
1683
|
+
<thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="AI spend today (UTC)">Today</th><th></th></tr></thead>
|
|
1050
1684
|
<tbody>
|
|
1051
1685
|
${rows}
|
|
1052
1686
|
</tbody>
|
|
1687
|
+
<tfoot><tr><td colspan="4" class="muted">AI spend today (UTC)</td><td class="muted" style="text-align:right">${formatUsd(totalToday)}</td><td></td></tr></tfoot>
|
|
1053
1688
|
</table>`;
|
|
1054
1689
|
return c.html(`<!doctype html>
|
|
1055
1690
|
<html lang="en">
|
|
@@ -1072,7 +1707,58 @@ ${body}
|
|
|
1072
1707
|
</html>
|
|
1073
1708
|
`);
|
|
1074
1709
|
});
|
|
1075
|
-
app.get("/api/apps", async (c) =>
|
|
1710
|
+
app.get("/api/apps", async (c) => {
|
|
1711
|
+
const records = await store.listInstalled();
|
|
1712
|
+
const apps = await Promise.all(records.map(async (record) => {
|
|
1713
|
+
const budget = await budgetStatus(store, usage, record.name);
|
|
1714
|
+
return {
|
|
1715
|
+
...record,
|
|
1716
|
+
todayCostUsd: budget.spentUsd,
|
|
1717
|
+
overBudget: budget.overBudget,
|
|
1718
|
+
...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
|
|
1719
|
+
};
|
|
1720
|
+
}));
|
|
1721
|
+
return c.json({ apps });
|
|
1722
|
+
});
|
|
1723
|
+
/** Spend and budget are host bookkeeping: readable even when the app fails to load. */
|
|
1724
|
+
async function installedApp(name) {
|
|
1725
|
+
return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
|
|
1726
|
+
}
|
|
1727
|
+
app.get("/api/apps/:name/usage", async (c) => {
|
|
1728
|
+
const name = c.req.param("name");
|
|
1729
|
+
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1730
|
+
const [summary, config] = await Promise.all([usage.summary(name), store.readGlobalConfig()]);
|
|
1731
|
+
return c.json({
|
|
1732
|
+
...summary,
|
|
1733
|
+
budget: budgetStatusFor(summary, appBudget(config, name))
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
app.get("/api/apps/:name/budget", async (c) => {
|
|
1737
|
+
const name = c.req.param("name");
|
|
1738
|
+
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1739
|
+
return c.json(await budgetStatus(store, usage, name));
|
|
1740
|
+
});
|
|
1741
|
+
/**
|
|
1742
|
+
* Sets the app's daily spend cap. A host setting, not an app config key: it
|
|
1743
|
+
* never touches `apps/<name>/config.json`, so the app cannot raise it via
|
|
1744
|
+
* PUT /api/apps/:name/config or by shipping a manifest default.
|
|
1745
|
+
*/
|
|
1746
|
+
app.put("/api/apps/:name/budget", async (c) => {
|
|
1747
|
+
const name = c.req.param("name");
|
|
1748
|
+
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1749
|
+
let body;
|
|
1750
|
+
try {
|
|
1751
|
+
body = await c.req.json();
|
|
1752
|
+
} catch {
|
|
1753
|
+
return c.json({ error: "Body must be JSON" }, 400);
|
|
1754
|
+
}
|
|
1755
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Body must be { \"dailyUsd\": number | null }" }, 400);
|
|
1756
|
+
const { dailyUsd } = body;
|
|
1757
|
+
if (dailyUsd !== null && (typeof dailyUsd !== "number" || !Number.isFinite(dailyUsd))) return c.json({ error: "\"dailyUsd\" must be a number, or null to remove the cap" }, 400);
|
|
1758
|
+
if (typeof dailyUsd === "number" && dailyUsd < 0) return c.json({ error: "\"dailyUsd\" must not be negative" }, 400);
|
|
1759
|
+
await setAppBudget(store, name, dailyUsd === null ? void 0 : dailyUsd);
|
|
1760
|
+
return c.json(await budgetStatus(store, usage, name));
|
|
1761
|
+
});
|
|
1076
1762
|
app.get("/settings/:name", async (c) => {
|
|
1077
1763
|
const name = c.req.param("name");
|
|
1078
1764
|
if (!APP_NAME_PATTERN.test(name) || !await store.readAppRecord(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
@@ -1241,6 +1927,12 @@ ${body}
|
|
|
1241
1927
|
}
|
|
1242
1928
|
if (typeof message !== "string" || message.length === 0) return c.json({ error: "Body must be { \"message\": string }" }, 400);
|
|
1243
1929
|
if (conversationId !== void 0 && typeof conversationId !== "string") return c.json({ error: "\"conversationId\" must be a string" }, 400);
|
|
1930
|
+
try {
|
|
1931
|
+
await assertWithinBudget(store, usage, name);
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
if (BudgetExceededError.is(err)) return c.json({ error: err.message }, 402);
|
|
1934
|
+
throw err;
|
|
1935
|
+
}
|
|
1244
1936
|
const state = await getChatState(name);
|
|
1245
1937
|
let convo;
|
|
1246
1938
|
if (conversationId !== void 0) {
|
|
@@ -1298,7 +1990,13 @@ ${body}
|
|
|
1298
1990
|
app.get("/app/:name", (c) => c.redirect(`/app/${c.req.param("name")}/`));
|
|
1299
1991
|
app.all("/app/:name/*", async (c) => {
|
|
1300
1992
|
const name = c.req.param("name");
|
|
1301
|
-
|
|
1993
|
+
let running;
|
|
1994
|
+
try {
|
|
1995
|
+
running = await getApp(name);
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
if (c.req.method === "GET" && (c.req.header("accept") ?? "").includes("text/html")) return c.html(appErrorPage(name, err), 500);
|
|
1998
|
+
throw err;
|
|
1999
|
+
}
|
|
1302
2000
|
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1303
2001
|
if (c.req.method === "GET" || c.req.method === "HEAD") {
|
|
1304
2002
|
const file = await serveStatic(name, running.manifest, c.req.path);
|
|
@@ -1383,6 +2081,9 @@ var Store = class {
|
|
|
1383
2081
|
appJobStatePath(name) {
|
|
1384
2082
|
return join(this.appDir(name), "jobs.json");
|
|
1385
2083
|
}
|
|
2084
|
+
appUsagePath(name) {
|
|
2085
|
+
return join(this.appDir(name), "usage.json");
|
|
2086
|
+
}
|
|
1386
2087
|
registryCachePath(name) {
|
|
1387
2088
|
if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
|
|
1388
2089
|
return join(this.registriesDir, `${name}.json`);
|
|
@@ -1421,6 +2122,12 @@ var Store = class {
|
|
|
1421
2122
|
async writeJobState(name, state) {
|
|
1422
2123
|
await writeJson(this.appJobStatePath(name), state);
|
|
1423
2124
|
}
|
|
2125
|
+
async readUsageState(name) {
|
|
2126
|
+
return { days: (await readJson(this.appUsagePath(name)))?.days ?? [] };
|
|
2127
|
+
}
|
|
2128
|
+
async writeUsageState(name, state) {
|
|
2129
|
+
await writeJson(this.appUsagePath(name), state);
|
|
2130
|
+
}
|
|
1424
2131
|
async listInstalled() {
|
|
1425
2132
|
let entries;
|
|
1426
2133
|
try {
|
|
@@ -1439,6 +2146,6 @@ var Store = class {
|
|
|
1439
2146
|
}
|
|
1440
2147
|
};
|
|
1441
2148
|
//#endregion
|
|
1442
|
-
export {
|
|
2149
|
+
export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, utcDay as M, setAppBudget as O, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
|
|
1443
2150
|
|
|
1444
|
-
//# sourceMappingURL=store-
|
|
2151
|
+
//# sourceMappingURL=store-VoJe_U1Q.mjs.map
|