@alashi/cli 0.14.0 → 0.15.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/assets/v1/chat.js +249 -55
- package/assets/v1/system.css +40 -0
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +272 -78
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{store-DoAGOynU.mjs → store-dipRcfKX.mjs} +815 -47
- package/dist/store-dipRcfKX.mjs.map +1 -0
- package/package.json +3 -3
- package/templates/basic-app/index.mjs +1 -1
- package/templates/basic-app/package.json +1 -1
- package/dist/store-DoAGOynU.mjs.map +0 -1
|
@@ -3,7 +3,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
|
3
3
|
import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
|
|
4
4
|
import semver from "semver";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
-
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { appendFile, cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -368,6 +368,25 @@ function round(value) {
|
|
|
368
368
|
function formatUsd(value) {
|
|
369
369
|
return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
|
|
370
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* The metering a turn actually contributes, or `undefined` when the engine
|
|
373
|
+
* reported nothing usable.
|
|
374
|
+
*
|
|
375
|
+
* A turn counts when its cost is a finite, non-negative number **or** it
|
|
376
|
+
* reported per-model tokens — an engine that omits both cannot corrupt the
|
|
377
|
+
* totals. This is the single gate for both writes: the ledger line and the
|
|
378
|
+
* usage.json fold must agree on what a turn is, or a rebuild from the ledger
|
|
379
|
+
* would disagree with the live aggregate.
|
|
380
|
+
*/
|
|
381
|
+
function meteredTurn(turn) {
|
|
382
|
+
const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
|
|
383
|
+
const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
|
|
384
|
+
if (!costValid && modelUsage === void 0) return void 0;
|
|
385
|
+
return {
|
|
386
|
+
costUsd: costValid ? turn.costUsd : 0,
|
|
387
|
+
...modelUsage !== void 0 && { modelUsage }
|
|
388
|
+
};
|
|
389
|
+
}
|
|
371
390
|
/** Merges per-app summaries into the host-wide view. */
|
|
372
391
|
function combineSummaries(summaries) {
|
|
373
392
|
const combined = {
|
|
@@ -453,38 +472,59 @@ function sanitizeModels(models) {
|
|
|
453
472
|
}
|
|
454
473
|
/**
|
|
455
474
|
* Per-app AI spend and token usage, persisted as OS bookkeeping in the app
|
|
456
|
-
* container
|
|
457
|
-
*
|
|
458
|
-
*
|
|
475
|
+
* container next to jobs.json and chat.json, in two files:
|
|
476
|
+
*
|
|
477
|
+
* - `apps/<name>/turns.jsonl` — the ledger: one line per metered turn,
|
|
478
|
+
* append-only, oldest first, carrying the session attribution the
|
|
479
|
+
* aggregates throw away.
|
|
480
|
+
* - `apps/<name>/usage.json` — a fold over that ledger: a per-UTC-day series
|
|
481
|
+
* trimmed to the retention window, plus per-month rollups kept forever.
|
|
482
|
+
*
|
|
483
|
+
* The two are written together inside one per-app queue, so a turn can never
|
|
484
|
+
* land in one and not the other, and concurrent turns can neither lose an
|
|
485
|
+
* update nor interleave a half-written line. Each app has its own queue, so a
|
|
486
|
+
* slow write for one app never delays another.
|
|
459
487
|
*
|
|
460
488
|
* Cost and per-model tokens arrive from the `result` event, whose fields the
|
|
461
489
|
* SDK contract marks optional: engines that report neither simply contribute
|
|
462
|
-
* nothing.
|
|
463
|
-
* update, and each app has its own queue so a slow write for one app never
|
|
464
|
-
* delays another.
|
|
490
|
+
* nothing.
|
|
465
491
|
*/
|
|
466
492
|
var UsageRecorder = class {
|
|
467
493
|
store;
|
|
468
494
|
retentionDays;
|
|
469
|
-
/** Serializes usage.json read-modify-write per app. */
|
|
495
|
+
/** Serializes the usage.json read-modify-write and ledger append per app. */
|
|
470
496
|
queues = /* @__PURE__ */ new Map();
|
|
497
|
+
/** Apps whose ledger has already been compacted in this process. */
|
|
498
|
+
compacted = /* @__PURE__ */ new Set();
|
|
471
499
|
constructor(store, retentionDays = 90) {
|
|
472
500
|
this.store = store;
|
|
473
501
|
this.retentionDays = retentionDays;
|
|
474
502
|
}
|
|
475
503
|
/**
|
|
476
|
-
*
|
|
477
|
-
* and month of `at`. A missing, non-finite, or
|
|
478
|
-
* ignored; a turn that reports neither valid cost nor
|
|
479
|
-
* not counted and nothing is written, so a
|
|
480
|
-
* cannot corrupt the totals.
|
|
504
|
+
* Appends the turn to the app's ledger and adds its cost and token usage to
|
|
505
|
+
* the totals for the UTC day and month of `at`. A missing, non-finite, or
|
|
506
|
+
* negative `costUsd` is ignored; a turn that reports neither valid cost nor
|
|
507
|
+
* any model usage is not counted and nothing is written at all, so a
|
|
508
|
+
* provider that omits metering cannot corrupt the totals.
|
|
509
|
+
*
|
|
510
|
+
* Both writes happen in the same queued unit of work, ledger first: if the
|
|
511
|
+
* aggregate write then fails, the ledger is *ahead* of usage.json and
|
|
512
|
+
* `rebuild()` can recover the turn. The other order would lose it.
|
|
481
513
|
*/
|
|
482
514
|
record(appName, turn, at = /* @__PURE__ */ new Date()) {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
515
|
+
const metered = meteredTurn(turn);
|
|
516
|
+
if (metered === void 0) return Promise.resolve();
|
|
517
|
+
const { costUsd, modelUsage } = metered;
|
|
518
|
+
const line = {
|
|
519
|
+
at: at.toISOString(),
|
|
520
|
+
...turn.sessionId !== void 0 && turn.sessionId !== "" && { sessionId: turn.sessionId },
|
|
521
|
+
...turn.model !== void 0 && turn.model !== "" && { model: turn.model },
|
|
522
|
+
costUsd,
|
|
523
|
+
...modelUsage !== void 0 && { modelUsage }
|
|
524
|
+
};
|
|
487
525
|
return this.enqueue(appName, async () => {
|
|
526
|
+
await this.compactOnce(appName, at);
|
|
527
|
+
await this.store.appendTurnRecord(appName, line);
|
|
488
528
|
const state = this.normalize(await this.store.readUsageState(appName));
|
|
489
529
|
const date = utcDay(at);
|
|
490
530
|
const day = state.days.find((entry) => entry.date === date);
|
|
@@ -539,6 +579,117 @@ var UsageRecorder = class {
|
|
|
539
579
|
months
|
|
540
580
|
};
|
|
541
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* The app's metered turns, oldest first, with expired lines excluded.
|
|
584
|
+
*
|
|
585
|
+
* `since` is an inclusive lower bound compared lexically against the ISO
|
|
586
|
+
* `at` (so a plain `YYYY-MM-DD` works as "from that day on"), `sessionId`
|
|
587
|
+
* narrows to one provider session, and both are ANDed. This is the one
|
|
588
|
+
* reader for the ledger: everything that needs per-turn detail goes through
|
|
589
|
+
* it rather than parsing the file itself.
|
|
590
|
+
*/
|
|
591
|
+
async turns(appName, query = {}, at = /* @__PURE__ */ new Date()) {
|
|
592
|
+
const cutoff = this.cutoff(at);
|
|
593
|
+
return (await this.store.readTurnRecords(appName)).filter((record) => this.inWindow(record, cutoff) && (query.since === void 0 || record.at >= query.since) && (query.sessionId === void 0 || record.sessionId === query.sessionId)).sort((a, b) => a.at.localeCompare(b.at));
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Rewrites the app's ledger without the lines that have aged out of the
|
|
597
|
+
* retention window (the same window usage.json days use). Unparseable lines
|
|
598
|
+
* are dropped by the same rewrite, since only parsed records are written
|
|
599
|
+
* back.
|
|
600
|
+
*
|
|
601
|
+
* Runs inside the per-app queue, so it can never race a turn being
|
|
602
|
+
* appended. Pure bookkeeping: callers should treat a failure as noise, not
|
|
603
|
+
* as a reason to fail whatever they were doing.
|
|
604
|
+
*/
|
|
605
|
+
compact(appName, at = /* @__PURE__ */ new Date()) {
|
|
606
|
+
return this.enqueue(appName, async () => {
|
|
607
|
+
await this.compactNow(appName, at);
|
|
608
|
+
this.compacted.add(appName);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Rebuilds usage.json from turns.jsonl, for when the two have drifted apart
|
|
613
|
+
* (a lost write, a crash mid-fold, a hand edit). The result is exactly what
|
|
614
|
+
* the incremental fold in `record()` would have produced from the same
|
|
615
|
+
* lines: same day and month buckets, same turn counts, same per-model
|
|
616
|
+
* totals, same rounding.
|
|
617
|
+
*
|
|
618
|
+
* Caveat, by construction: the ledger is pruned at `retentionDays` but
|
|
619
|
+
* usage.json months are kept forever, so months the ledger no longer covers
|
|
620
|
+
* cannot be recomputed. Those keep their existing rollup and only the
|
|
621
|
+
* months the ledger does cover are replaced — a rebuild never silently
|
|
622
|
+
* erases older history. The one month that is *partially* covered (the one
|
|
623
|
+
* the retention cutoff falls in) is replaced by its in-window part, which is
|
|
624
|
+
* all the ledger can still account for.
|
|
625
|
+
*
|
|
626
|
+
* Returns the state it wrote.
|
|
627
|
+
*/
|
|
628
|
+
rebuild(appName, at = /* @__PURE__ */ new Date()) {
|
|
629
|
+
return this.enqueue(appName, async () => {
|
|
630
|
+
const cutoff = this.cutoff(at);
|
|
631
|
+
const [records, existing] = await Promise.all([this.store.readTurnRecords(appName), this.store.readUsageState(appName)]);
|
|
632
|
+
const previous = this.normalize(existing);
|
|
633
|
+
const days = /* @__PURE__ */ new Map();
|
|
634
|
+
const months = /* @__PURE__ */ new Map();
|
|
635
|
+
for (const record of records) {
|
|
636
|
+
if (!this.inWindow(record, cutoff)) continue;
|
|
637
|
+
const metered = meteredTurn(record);
|
|
638
|
+
if (metered === void 0) continue;
|
|
639
|
+
const date = record.at.slice(0, 10);
|
|
640
|
+
const monthKey = record.at.slice(0, 7);
|
|
641
|
+
const day = days.get(date) ?? {
|
|
642
|
+
date,
|
|
643
|
+
costUsd: 0,
|
|
644
|
+
turns: 0
|
|
645
|
+
};
|
|
646
|
+
this.apply(day, metered.costUsd, metered.modelUsage);
|
|
647
|
+
days.set(date, day);
|
|
648
|
+
const month = months.get(monthKey) ?? {
|
|
649
|
+
month: monthKey,
|
|
650
|
+
costUsd: 0,
|
|
651
|
+
turns: 0
|
|
652
|
+
};
|
|
653
|
+
this.apply(month, metered.costUsd, metered.modelUsage);
|
|
654
|
+
months.set(monthKey, month);
|
|
655
|
+
}
|
|
656
|
+
const kept = previous.months.filter((month) => !months.has(month.month));
|
|
657
|
+
const state = this.prune({
|
|
658
|
+
days: [...days.values()],
|
|
659
|
+
months: [...kept, ...months.values()]
|
|
660
|
+
}, at);
|
|
661
|
+
await this.store.writeUsageState(appName, state);
|
|
662
|
+
return state;
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
/** The oldest UTC day still inside the retention window at `at`. */
|
|
666
|
+
cutoff(at) {
|
|
667
|
+
return utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
|
|
668
|
+
}
|
|
669
|
+
inWindow(record, cutoff) {
|
|
670
|
+
return record.at.slice(0, 10) >= cutoff;
|
|
671
|
+
}
|
|
672
|
+
/** The compaction body; assumes it is already inside the app's queue. */
|
|
673
|
+
async compactNow(appName, at) {
|
|
674
|
+
const cutoff = this.cutoff(at);
|
|
675
|
+
const records = await this.store.readTurnRecords(appName);
|
|
676
|
+
const kept = records.filter((record) => this.inWindow(record, cutoff));
|
|
677
|
+
if (kept.length === records.length) return;
|
|
678
|
+
await this.store.writeTurnRecords(appName, kept);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Trims the ledger on the app's first append of the process, so a host that
|
|
682
|
+
* is never restarted still sheds expired lines. Compaction is bookkeeping
|
|
683
|
+
* and must never break a turn, so a failure is swallowed — the next process
|
|
684
|
+
* tries again, and meanwhile `turns()` filters expired lines out anyway.
|
|
685
|
+
*/
|
|
686
|
+
async compactOnce(appName, at) {
|
|
687
|
+
if (this.compacted.has(appName)) return;
|
|
688
|
+
this.compacted.add(appName);
|
|
689
|
+
try {
|
|
690
|
+
await this.compactNow(appName, at);
|
|
691
|
+
} catch {}
|
|
692
|
+
}
|
|
542
693
|
apply(bucket, costUsd, modelUsage) {
|
|
543
694
|
bucket.costUsd = round(bucket.costUsd + costUsd);
|
|
544
695
|
bucket.turns += 1;
|
|
@@ -582,7 +733,7 @@ var UsageRecorder = class {
|
|
|
582
733
|
}
|
|
583
734
|
/** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
|
|
584
735
|
prune(state, at) {
|
|
585
|
-
const cutoff =
|
|
736
|
+
const cutoff = this.cutoff(at);
|
|
586
737
|
return {
|
|
587
738
|
days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
588
739
|
const models = sanitizeModels(entry.models);
|
|
@@ -1355,8 +1506,14 @@ function createLogger(prefix) {
|
|
|
1355
1506
|
* event reaches the caller. This sits under *every* session an app opens —
|
|
1356
1507
|
* chat turns, cron job handlers, and app routes alike — so spend is recorded
|
|
1357
1508
|
* no matter who opened the session.
|
|
1509
|
+
*
|
|
1510
|
+
* The session id and model come from the `session` event, which a provider
|
|
1511
|
+
* emits once and which then holds for every later turn — so they are
|
|
1512
|
+
* remembered in this closure, not per `send()` call.
|
|
1358
1513
|
*/
|
|
1359
1514
|
function meterSession(session, recordUsage) {
|
|
1515
|
+
let sessionId;
|
|
1516
|
+
let model;
|
|
1360
1517
|
return {
|
|
1361
1518
|
get id() {
|
|
1362
1519
|
return session.id;
|
|
@@ -1365,16 +1522,66 @@ function meterSession(session, recordUsage) {
|
|
|
1365
1522
|
const events = session.send(input);
|
|
1366
1523
|
return (async function* metered() {
|
|
1367
1524
|
for await (const event of events) {
|
|
1368
|
-
if (event.type === "
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
}
|
|
1525
|
+
if (event.type === "session") {
|
|
1526
|
+
if (event.sessionId !== "") sessionId = event.sessionId;
|
|
1527
|
+
if (event.model !== void 0 && event.model !== "") model = event.model;
|
|
1528
|
+
}
|
|
1529
|
+
if (event.type === "result") {
|
|
1530
|
+
const id = sessionId ?? (session.id === "" ? void 0 : session.id);
|
|
1531
|
+
await recordUsage({
|
|
1532
|
+
costUsd: event.costUsd,
|
|
1533
|
+
modelUsage: event.modelUsage,
|
|
1534
|
+
...id !== void 0 && { sessionId: id },
|
|
1535
|
+
...model !== void 0 && { model }
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1372
1538
|
yield event;
|
|
1373
1539
|
}
|
|
1374
1540
|
})();
|
|
1375
1541
|
},
|
|
1376
1542
|
interrupt: () => session.interrupt(),
|
|
1377
|
-
close: () => session.close()
|
|
1543
|
+
close: () => session.close(),
|
|
1544
|
+
...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Wraps a session so its approval traffic is mirrored into the host-wide
|
|
1549
|
+
* ApprovalRegistry. Layered like `meterSession` under every session an app
|
|
1550
|
+
* opens, which is what makes a cron job's approval visible in the host's
|
|
1551
|
+
* pending list instead of hanging silently until timeout.
|
|
1552
|
+
*
|
|
1553
|
+
* A stream that ends while approvals are still pending (crash, close) abandons
|
|
1554
|
+
* them, so the registry never carries a pending entry no session can answer.
|
|
1555
|
+
*/
|
|
1556
|
+
function superviseSession(session, app, registry) {
|
|
1557
|
+
return {
|
|
1558
|
+
get id() {
|
|
1559
|
+
return session.id;
|
|
1560
|
+
},
|
|
1561
|
+
send(input) {
|
|
1562
|
+
const events = session.send(input);
|
|
1563
|
+
const open = /* @__PURE__ */ new Set();
|
|
1564
|
+
return (async function* supervised() {
|
|
1565
|
+
try {
|
|
1566
|
+
for await (const event of events) {
|
|
1567
|
+
if (event.type === "approval_required") {
|
|
1568
|
+
open.add(event.approvalId);
|
|
1569
|
+
registry.track(app, event, (decision) => session.respondToApproval ? session.respondToApproval(event.approvalId, decision) : Promise.resolve());
|
|
1570
|
+
}
|
|
1571
|
+
if (event.type === "approval_resolved") {
|
|
1572
|
+
open.delete(event.approvalId);
|
|
1573
|
+
registry.resolved(event);
|
|
1574
|
+
}
|
|
1575
|
+
yield event;
|
|
1576
|
+
}
|
|
1577
|
+
} finally {
|
|
1578
|
+
for (const approvalId of open) registry.abandon(approvalId);
|
|
1579
|
+
}
|
|
1580
|
+
})();
|
|
1581
|
+
},
|
|
1582
|
+
interrupt: () => session.interrupt(),
|
|
1583
|
+
close: () => session.close(),
|
|
1584
|
+
...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
|
|
1378
1585
|
};
|
|
1379
1586
|
}
|
|
1380
1587
|
/**
|
|
@@ -1393,26 +1600,80 @@ function scopeProvider(provider, defaults) {
|
|
|
1393
1600
|
...defaults.mcpServers,
|
|
1394
1601
|
...options.mcpServers
|
|
1395
1602
|
};
|
|
1396
|
-
const
|
|
1603
|
+
const granted = (defaults.readGrants ? await defaults.readGrants() : []).filter((tool) => defaults.askTools.includes(tool));
|
|
1604
|
+
const allowedTools = [.../* @__PURE__ */ new Set([
|
|
1605
|
+
...defaults.allowedTools,
|
|
1606
|
+
...granted,
|
|
1607
|
+
...options.allowedTools ?? []
|
|
1608
|
+
])];
|
|
1609
|
+
const askTools = [.../* @__PURE__ */ new Set([...defaults.askTools.filter((tool) => !granted.includes(tool)), ...options.askTools ?? []])];
|
|
1397
1610
|
const agents = {
|
|
1398
1611
|
...defaults.agents,
|
|
1399
1612
|
...options.agents
|
|
1400
1613
|
};
|
|
1401
1614
|
const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
|
|
1402
|
-
|
|
1615
|
+
let session = await provider.createSession({
|
|
1403
1616
|
cwd: defaults.cwd,
|
|
1404
1617
|
...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
|
|
1405
1618
|
...options,
|
|
1406
1619
|
...Object.keys(mcpServers).length > 0 && { mcpServers },
|
|
1407
1620
|
...allowedTools.length > 0 && { allowedTools },
|
|
1621
|
+
...askTools.length > 0 && { askTools },
|
|
1408
1622
|
...Object.keys(agents).length > 0 && { agents },
|
|
1409
1623
|
...skillDirs.length > 0 && { skillDirs }
|
|
1410
1624
|
});
|
|
1625
|
+
if (defaults.supervise) session = defaults.supervise(session);
|
|
1411
1626
|
return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
|
|
1412
1627
|
}
|
|
1413
1628
|
};
|
|
1414
1629
|
}
|
|
1415
1630
|
/**
|
|
1631
|
+
* A summary that says "nothing has been metered", for a context built without
|
|
1632
|
+
* a recorder (tests, and callers that only need config and state).
|
|
1633
|
+
*
|
|
1634
|
+
* `recorded: false` is the whole point: every number here is an *absence of
|
|
1635
|
+
* data*, not a measured zero, and a reader that treats the difference
|
|
1636
|
+
* seriously must be able to tell them apart. Built fresh per call so a caller
|
|
1637
|
+
* that mutates `days` or `models` cannot poison the next reader.
|
|
1638
|
+
*/
|
|
1639
|
+
function unrecordedSummary() {
|
|
1640
|
+
return {
|
|
1641
|
+
date: utcDay(),
|
|
1642
|
+
todayCostUsd: 0,
|
|
1643
|
+
todayTurns: 0,
|
|
1644
|
+
todayModels: {},
|
|
1645
|
+
totalCostUsd: 0,
|
|
1646
|
+
models: {},
|
|
1647
|
+
retentionDays: 90,
|
|
1648
|
+
recorded: false,
|
|
1649
|
+
days: [],
|
|
1650
|
+
months: []
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* The app's read-only window onto its own spend, in-process like `ctx.state`.
|
|
1655
|
+
*
|
|
1656
|
+
* The app name is closed over here and never taken as an argument, so an app
|
|
1657
|
+
* can only ever read its own ledger: there is no parameter to point at another
|
|
1658
|
+
* app, no HTTP hop to spoof, and nothing on this surface that writes. The
|
|
1659
|
+
* recorder's own reads already filter to the retention window, so what an app
|
|
1660
|
+
* sees is exactly what the host kept.
|
|
1661
|
+
*
|
|
1662
|
+
* Without a recorder nothing is metered, so the honest answer is "no data":
|
|
1663
|
+
* `turns()` is empty and `summary()` reports `recorded: false` rather than a
|
|
1664
|
+
* fabricated zero.
|
|
1665
|
+
*/
|
|
1666
|
+
function appUsage(name, usage) {
|
|
1667
|
+
if (usage === void 0) return {
|
|
1668
|
+
turns: () => Promise.resolve([]),
|
|
1669
|
+
summary: () => Promise.resolve(unrecordedSummary())
|
|
1670
|
+
};
|
|
1671
|
+
return {
|
|
1672
|
+
turns: (query) => usage.turns(name, query),
|
|
1673
|
+
summary: () => usage.summary(name)
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1416
1677
|
* Reads and parses the manifest-declared agent files. The agent's name is
|
|
1417
1678
|
* the filename without the `.md` extension.
|
|
1418
1679
|
*/
|
|
@@ -1450,10 +1711,13 @@ async function resolveAppConfig(store, manifest) {
|
|
|
1450
1711
|
* while required keys are missing the scoped provider fails closed.
|
|
1451
1712
|
*
|
|
1452
1713
|
* When a `usage` recorder is supplied, every session opened through the
|
|
1453
|
-
* returned context reports its per-turn cost to it,
|
|
1454
|
-
*
|
|
1714
|
+
* returned context reports its per-turn cost to it, the app's host-set daily
|
|
1715
|
+
* spend cap (if any) is enforced on every createSession, and `ctx.usage` reads
|
|
1716
|
+
* back the app's own ledger. Without one there is no metering, no cap, and
|
|
1717
|
+
* `ctx.usage` answers "nothing recorded" — the member is required by the
|
|
1718
|
+
* contract, so it is always present.
|
|
1455
1719
|
*/
|
|
1456
|
-
async function buildAppContext(store, manifest, provider, usage) {
|
|
1720
|
+
async function buildAppContext(store, manifest, provider, usage, approvals) {
|
|
1457
1721
|
const name = manifest.name;
|
|
1458
1722
|
const dataDir = store.appDataDir(name);
|
|
1459
1723
|
const repoDir = store.appRepoDir(name);
|
|
@@ -1470,6 +1734,11 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1470
1734
|
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
1471
1735
|
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
1472
1736
|
const logger = createLogger(name);
|
|
1737
|
+
let askTools = manifest.permissions.askTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
1738
|
+
if (askTools.length > 0 && provider.supportsApprovals !== true) {
|
|
1739
|
+
logger.warn(`AI engine "${provider.name}" does not support interactive approvals; ask-tier tools are disabled`, { askTools });
|
|
1740
|
+
askTools = [];
|
|
1741
|
+
}
|
|
1473
1742
|
const recordUsage = usage ? async (turn) => {
|
|
1474
1743
|
try {
|
|
1475
1744
|
await usage.record(name, turn);
|
|
@@ -1483,9 +1752,15 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1483
1752
|
config,
|
|
1484
1753
|
logger,
|
|
1485
1754
|
state: new JsonFileStore(join(dataDir, "state.json")),
|
|
1755
|
+
usage: appUsage(name, usage),
|
|
1486
1756
|
ai: scopeProvider(provider, {
|
|
1487
1757
|
mcpServers,
|
|
1488
1758
|
allowedTools,
|
|
1759
|
+
askTools,
|
|
1760
|
+
...askTools.length > 0 && {
|
|
1761
|
+
readGrants: async () => (await store.readGrants(name)).tools,
|
|
1762
|
+
...approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) }
|
|
1763
|
+
},
|
|
1489
1764
|
agents: await loadAgents(repoDir, manifest.agents),
|
|
1490
1765
|
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
1491
1766
|
...instructions !== void 0 && { instructions },
|
|
@@ -1496,6 +1771,253 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1496
1771
|
})
|
|
1497
1772
|
};
|
|
1498
1773
|
}
|
|
1774
|
+
const APPROVAL_LOG_CAP = 100;
|
|
1775
|
+
/** Settled records kept in memory so a late responder gets 409, not 404. */
|
|
1776
|
+
const SETTLED_CAP = 200;
|
|
1777
|
+
/**
|
|
1778
|
+
* Host-wide bookkeeping for interactive tool approvals. Sessions wrapped by
|
|
1779
|
+
* `superviseSession` register every `approval_required` here, so chat turns
|
|
1780
|
+
* and cron jobs alike show up in one pending list; decisions flow back through
|
|
1781
|
+
* `respond()` to the provider, and every outcome is appended to the app's
|
|
1782
|
+
* `approvals.json` log. The registry also arms the host's timeout policy:
|
|
1783
|
+
* a request nobody decides is denied after {@link DEFAULT_APPROVAL_TIMEOUT_MS}
|
|
1784
|
+
* (overridable via `approvalTimeoutSeconds` in the global config — host
|
|
1785
|
+
* policy, like budgets).
|
|
1786
|
+
*
|
|
1787
|
+
* Always-allow grants (`grants.json`) are written here too, so ask→allow
|
|
1788
|
+
* promotion stays inside manifest bounds in one place.
|
|
1789
|
+
*/
|
|
1790
|
+
var ApprovalRegistry = class {
|
|
1791
|
+
#store;
|
|
1792
|
+
#timeoutMs;
|
|
1793
|
+
#pending = /* @__PURE__ */ new Map();
|
|
1794
|
+
#settled = /* @__PURE__ */ new Map();
|
|
1795
|
+
/** Serializes approvals.json read-modify-write across apps and timers. */
|
|
1796
|
+
#logQueue = Promise.resolve();
|
|
1797
|
+
constructor(store, options = {}) {
|
|
1798
|
+
this.#store = store;
|
|
1799
|
+
this.#timeoutMs = options.timeoutMs ?? 3e5;
|
|
1800
|
+
}
|
|
1801
|
+
/** Applies the host-config timeout override; safe to fire-and-forget at boot. */
|
|
1802
|
+
async init() {
|
|
1803
|
+
const seconds = (await this.#store.readGlobalConfig()).approvalTimeoutSeconds;
|
|
1804
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) this.#timeoutMs = seconds * 1e3;
|
|
1805
|
+
}
|
|
1806
|
+
/** Registers a provider-born approval request and arms the auto-deny timer. */
|
|
1807
|
+
track(app, event, respond) {
|
|
1808
|
+
if (this.#pending.has(event.approvalId)) return;
|
|
1809
|
+
const now = Date.now();
|
|
1810
|
+
const record = {
|
|
1811
|
+
approvalId: event.approvalId,
|
|
1812
|
+
app,
|
|
1813
|
+
tool: event.tool,
|
|
1814
|
+
input: event.input,
|
|
1815
|
+
requestedAt: new Date(now).toISOString(),
|
|
1816
|
+
expiresAt: new Date(now + this.#timeoutMs).toISOString(),
|
|
1817
|
+
status: "pending"
|
|
1818
|
+
};
|
|
1819
|
+
const timer = setTimeout(() => this.#timeout(event.approvalId), this.#timeoutMs);
|
|
1820
|
+
timer.unref?.();
|
|
1821
|
+
this.#pending.set(event.approvalId, {
|
|
1822
|
+
record,
|
|
1823
|
+
respond,
|
|
1824
|
+
timer
|
|
1825
|
+
});
|
|
1826
|
+
this.#mutateLog(app, (approvals) => approvals.push({ ...record }));
|
|
1827
|
+
}
|
|
1828
|
+
/** Attaches the conversation a chat-born approval belongs to (cron ones have none). */
|
|
1829
|
+
annotate(approvalId, fields) {
|
|
1830
|
+
const pending = this.#pending.get(approvalId);
|
|
1831
|
+
if (!pending) return;
|
|
1832
|
+
pending.record.conversationId = fields.conversationId;
|
|
1833
|
+
this.#updateLog(pending.record);
|
|
1834
|
+
}
|
|
1835
|
+
/** The pending or settled record, for endpoint lookups. */
|
|
1836
|
+
get(approvalId) {
|
|
1837
|
+
return this.#pending.get(approvalId)?.record ?? this.#settled.get(approvalId);
|
|
1838
|
+
}
|
|
1839
|
+
/** Snapshots of pending records, optionally narrowed; newest last. */
|
|
1840
|
+
pending(filter = {}) {
|
|
1841
|
+
const records = [];
|
|
1842
|
+
for (const { record } of this.#pending.values()) {
|
|
1843
|
+
if (filter.app !== void 0 && record.app !== filter.app) continue;
|
|
1844
|
+
if (filter.conversationId !== void 0 && record.conversationId !== filter.conversationId) continue;
|
|
1845
|
+
records.push({ ...record });
|
|
1846
|
+
}
|
|
1847
|
+
return records;
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Applies a user decision: settles the record, then forwards the decision to
|
|
1851
|
+
* the session that is suspended on it. Returns 'unknown' for an id this host
|
|
1852
|
+
* never saw and 'resolved' for one already decided, so the endpoint can tell
|
|
1853
|
+
* 404 from 409.
|
|
1854
|
+
*/
|
|
1855
|
+
respond(approvalId, decision) {
|
|
1856
|
+
const pending = this.#pending.get(approvalId);
|
|
1857
|
+
if (!pending) return this.#settled.has(approvalId) ? "resolved" : "unknown";
|
|
1858
|
+
this.#settle(pending, decision.behavior === "allow" ? "allowed" : "denied", decision.behavior === "allow" ? decision.scope : void 0);
|
|
1859
|
+
pending.respond(decision).catch(() => void 0);
|
|
1860
|
+
return "ok";
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Observes a provider-side resolution (session closed, interrupt) arriving
|
|
1864
|
+
* through the event stream. A registry-initiated decision already settled
|
|
1865
|
+
* the record, so this is a no-op for it.
|
|
1866
|
+
*/
|
|
1867
|
+
resolved(event) {
|
|
1868
|
+
const pending = this.#pending.get(event.approvalId);
|
|
1869
|
+
if (!pending) return;
|
|
1870
|
+
const status = event.behavior === "allow" ? "allowed" : event.reason === "timeout" ? "timeout" : "denied";
|
|
1871
|
+
this.#settle(pending, status);
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Marks an approval whose session's event stream ended without a resolution
|
|
1875
|
+
* (crash, abrupt close) as denied, so nothing stays pending forever.
|
|
1876
|
+
*/
|
|
1877
|
+
abandon(approvalId) {
|
|
1878
|
+
const pending = this.#pending.get(approvalId);
|
|
1879
|
+
if (!pending) return;
|
|
1880
|
+
this.#settle(pending, "denied");
|
|
1881
|
+
}
|
|
1882
|
+
/** Reads the app's always-allow grants. */
|
|
1883
|
+
async grants(app) {
|
|
1884
|
+
return (await this.#store.readGrants(app)).tools;
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Persists an always-allow grant. Validated against the manifest's askTools:
|
|
1888
|
+
* a grant can promote ask→allow but never allow a tool the manifest did not
|
|
1889
|
+
* declare askable.
|
|
1890
|
+
*/
|
|
1891
|
+
async addGrant(app, tool, askTools) {
|
|
1892
|
+
if (!askTools.includes(tool)) throw new AppSdkError(`Tool "${tool}" is not an ask-tier tool of app "${app}"`);
|
|
1893
|
+
const state = await this.#store.readGrants(app);
|
|
1894
|
+
if (state.tools.includes(tool)) return;
|
|
1895
|
+
state.tools.push(tool);
|
|
1896
|
+
await this.#store.writeGrants(app, state);
|
|
1897
|
+
}
|
|
1898
|
+
/** Clears timers and denies everything still pending (host shutdown). */
|
|
1899
|
+
close() {
|
|
1900
|
+
for (const approvalId of [...this.#pending.keys()]) this.abandon(approvalId);
|
|
1901
|
+
}
|
|
1902
|
+
#timeout(approvalId) {
|
|
1903
|
+
const pending = this.#pending.get(approvalId);
|
|
1904
|
+
if (!pending) return;
|
|
1905
|
+
this.#settle(pending, "timeout");
|
|
1906
|
+
pending.respond({
|
|
1907
|
+
behavior: "deny",
|
|
1908
|
+
message: "Approval timed out",
|
|
1909
|
+
reason: "timeout"
|
|
1910
|
+
}).catch(() => void 0);
|
|
1911
|
+
}
|
|
1912
|
+
#settle(pending, status, scope) {
|
|
1913
|
+
clearTimeout(pending.timer);
|
|
1914
|
+
this.#pending.delete(pending.record.approvalId);
|
|
1915
|
+
pending.record.status = status;
|
|
1916
|
+
pending.record.decidedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1917
|
+
if (scope !== void 0) pending.record.scope = scope;
|
|
1918
|
+
this.#settled.set(pending.record.approvalId, pending.record);
|
|
1919
|
+
if (this.#settled.size > SETTLED_CAP) {
|
|
1920
|
+
const oldest = this.#settled.keys().next().value;
|
|
1921
|
+
if (oldest !== void 0) this.#settled.delete(oldest);
|
|
1922
|
+
}
|
|
1923
|
+
this.#updateLog(pending.record);
|
|
1924
|
+
}
|
|
1925
|
+
#updateLog(record) {
|
|
1926
|
+
this.#mutateLog(record.app, (approvals) => {
|
|
1927
|
+
const index = approvals.findIndex((entry) => entry.approvalId === record.approvalId);
|
|
1928
|
+
if (index === -1) approvals.push({ ...record });
|
|
1929
|
+
else approvals[index] = { ...record };
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
#mutateLog(app, mutate) {
|
|
1933
|
+
const update = this.#logQueue.then(async () => {
|
|
1934
|
+
const log = await this.#store.readApprovalLog(app);
|
|
1935
|
+
mutate(log.approvals);
|
|
1936
|
+
if (log.approvals.length > APPROVAL_LOG_CAP) log.approvals.splice(0, log.approvals.length - APPROVAL_LOG_CAP);
|
|
1937
|
+
await this.#store.writeApprovalLog(app, log);
|
|
1938
|
+
});
|
|
1939
|
+
this.#logQueue = update.catch(() => void 0);
|
|
1940
|
+
}
|
|
1941
|
+
/** Resolves when all queued approvals.json writes have landed (tests). */
|
|
1942
|
+
flush() {
|
|
1943
|
+
return this.#logQueue.then(() => void 0);
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/chat-bus.ts
|
|
1948
|
+
const RING_CAP = 200;
|
|
1949
|
+
/**
|
|
1950
|
+
* Per-conversation event bus: POST /chat enqueues a turn whose events are
|
|
1951
|
+
* published here, and any number of SSE subscribers replay then follow them.
|
|
1952
|
+
* Ids are monotonic per bus; the ring keeps the last {@link RING_CAP} entries
|
|
1953
|
+
* so a reconnecting subscriber can resume from its Last-Event-ID.
|
|
1954
|
+
*/
|
|
1955
|
+
var ConversationBus = class {
|
|
1956
|
+
#nextId = 1;
|
|
1957
|
+
#ring = [];
|
|
1958
|
+
#wakers = [];
|
|
1959
|
+
publish(event) {
|
|
1960
|
+
const entry = {
|
|
1961
|
+
id: this.#nextId++,
|
|
1962
|
+
event
|
|
1963
|
+
};
|
|
1964
|
+
this.#ring.push(entry);
|
|
1965
|
+
if (this.#ring.length > RING_CAP) this.#ring.shift();
|
|
1966
|
+
for (const wake of this.#wakers.splice(0)) wake();
|
|
1967
|
+
return entry.id;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Replay then live. With a `lastEventId` still covered by the ring, replay
|
|
1971
|
+
* resumes right after it; one that scrolled out yields a `resync` first.
|
|
1972
|
+
* Without one (a fresh subscriber), replay starts at the current turn — the
|
|
1973
|
+
* entry after the newest `result` — because everything before it is already
|
|
1974
|
+
* served by the history endpoint and would render twice.
|
|
1975
|
+
*
|
|
1976
|
+
* The iterator only ends via the signal (client disconnect); the bus itself
|
|
1977
|
+
* never closes.
|
|
1978
|
+
*/
|
|
1979
|
+
async *subscribe(lastEventId, signal) {
|
|
1980
|
+
let cursor;
|
|
1981
|
+
if (lastEventId === void 0) cursor = this.#turnStart();
|
|
1982
|
+
else {
|
|
1983
|
+
const oldest = this.#ring[0]?.id;
|
|
1984
|
+
if (oldest !== void 0 && lastEventId < oldest - 1) {
|
|
1985
|
+
yield {
|
|
1986
|
+
id: this.#nextId - 1,
|
|
1987
|
+
event: { type: "resync" }
|
|
1988
|
+
};
|
|
1989
|
+
cursor = this.#turnStart();
|
|
1990
|
+
} else cursor = lastEventId + 1;
|
|
1991
|
+
}
|
|
1992
|
+
while (!signal?.aborted) {
|
|
1993
|
+
const next = this.#ring.find((entry) => entry.id >= cursor);
|
|
1994
|
+
if (next) {
|
|
1995
|
+
cursor = next.id + 1;
|
|
1996
|
+
yield next;
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
await this.#nextPublish(signal);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/** The id right after the newest `result` in the ring — the in-flight turn's start. */
|
|
2003
|
+
#turnStart() {
|
|
2004
|
+
for (let i = this.#ring.length - 1; i >= 0; i--) {
|
|
2005
|
+
const entry = this.#ring[i];
|
|
2006
|
+
if (entry !== void 0 && entry.event.type === "result") return entry.id + 1;
|
|
2007
|
+
}
|
|
2008
|
+
return this.#ring[0]?.id ?? this.#nextId;
|
|
2009
|
+
}
|
|
2010
|
+
#nextPublish(signal) {
|
|
2011
|
+
return new Promise((resolve) => {
|
|
2012
|
+
const onAbort = () => resolve();
|
|
2013
|
+
this.#wakers.push(() => {
|
|
2014
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2015
|
+
resolve();
|
|
2016
|
+
});
|
|
2017
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
1499
2021
|
//#endregion
|
|
1500
2022
|
//#region src/scheduler.ts
|
|
1501
2023
|
const RUN_LOG_CAP = 50;
|
|
@@ -2136,14 +2658,32 @@ function appErrorPage(name, err) {
|
|
|
2136
2658
|
* GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
|
|
2137
2659
|
* per-model token totals, monthly rollups,
|
|
2138
2660
|
* `recorded`, and the budget status
|
|
2661
|
+
* GET /api/apps/:name/turns the per-turn ledger that summary folds: `{ turns }`,
|
|
2662
|
+
* oldest first, each with `at`, `costUsd`, and the
|
|
2663
|
+
* session/model attribution the buckets drop.
|
|
2664
|
+
* ?since=<timestamp> (inclusive) and ?session=<id>
|
|
2665
|
+
* narrow it, ANDed; an unreadable `since` is a 400
|
|
2139
2666
|
* GET /api/apps/:name/budget today's spend against the app's daily cap,
|
|
2140
2667
|
* plus `recorded`
|
|
2141
2668
|
* PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
|
|
2142
2669
|
* (a host setting; apps cannot set their own)
|
|
2143
|
-
* POST /api/apps/:name/chat { message, conversationId? } ->
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2670
|
+
* POST /api/apps/:name/chat { message, conversationId? } -> 202 { conversationId };
|
|
2671
|
+
* the turn runs detached and its AgentEvents are published
|
|
2672
|
+
* on the conversation's event bus (omit conversationId to
|
|
2673
|
+
* start a new conversation; the id is also echoed in the
|
|
2674
|
+
* x-conversation-id response header; 402 when the app is
|
|
2675
|
+
* over its daily spend cap)
|
|
2676
|
+
* GET /api/apps/:name/chat/:conversationId/events standing SSE from the conversation
|
|
2677
|
+
* bus: replay from Last-Event-ID (or ?lastEventId=), then
|
|
2678
|
+
* live; a scrolled-out id gets a `resync` event first
|
|
2679
|
+
* GET /api/apps/:name/chat/:conversationId/pending-approvals reconnect snapshot of
|
|
2680
|
+
* undecided tool approvals for this conversation
|
|
2681
|
+
* POST /api/apps/:name/chat/:conversationId/approvals/:approvalId
|
|
2682
|
+
* { behavior: "allow"|"deny", scope?, message? } -> decide a
|
|
2683
|
+
* pending approval (scope "always" persists a grant);
|
|
2684
|
+
* 404 unknown/expired, 409 already decided
|
|
2685
|
+
* GET /api/approvals host-wide pending tool approvals (chat and cron alike)
|
|
2686
|
+
* POST /api/approvals/:approvalId decide a pending approval regardless of source
|
|
2147
2687
|
* GET /api/apps/:name/chat/conversations conversations, most recent first
|
|
2148
2688
|
* POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
|
|
2149
2689
|
* optionally adopting an existing provider session (e.g. an
|
|
@@ -2168,9 +2708,18 @@ function createServer(store, provider, options = {}) {
|
|
|
2168
2708
|
const apps = /* @__PURE__ */ new Map();
|
|
2169
2709
|
const sessions = /* @__PURE__ */ new Map();
|
|
2170
2710
|
const usage = new UsageRecorder(store);
|
|
2711
|
+
const approvals = new ApprovalRegistry(store);
|
|
2712
|
+
approvals.init().catch((err) => {
|
|
2713
|
+
console.warn(`alashi: failed to read approval timeout config: ${String(err)}`);
|
|
2714
|
+
});
|
|
2715
|
+
store.listInstalled().then((records) => Promise.all(records.map((record) => usage.compact(record.name).catch((err) => {
|
|
2716
|
+
console.warn(`alashi: failed to compact turns.jsonl for "${record.name}": ${String(err)}`);
|
|
2717
|
+
})))).catch((err) => {
|
|
2718
|
+
console.warn(`alashi: failed to compact turn ledgers: ${String(err)}`);
|
|
2719
|
+
});
|
|
2171
2720
|
async function startApp(name) {
|
|
2172
2721
|
const { definition, manifest } = await loadApp(store.appRepoDir(name));
|
|
2173
|
-
const ctx = await buildAppContext(store, manifest, provider, usage);
|
|
2722
|
+
const ctx = await buildAppContext(store, manifest, provider, usage, approvals);
|
|
2174
2723
|
return {
|
|
2175
2724
|
manifest,
|
|
2176
2725
|
ctx,
|
|
@@ -2215,6 +2764,28 @@ function createServer(store, provider, options = {}) {
|
|
|
2215
2764
|
}
|
|
2216
2765
|
return session;
|
|
2217
2766
|
}
|
|
2767
|
+
/** One bus per conversation; created on first use, kept for reconnects. */
|
|
2768
|
+
const buses = /* @__PURE__ */ new Map();
|
|
2769
|
+
function getBus(name, conversationId) {
|
|
2770
|
+
const key = `${name}/${conversationId}`;
|
|
2771
|
+
let bus = buses.get(key);
|
|
2772
|
+
if (!bus) {
|
|
2773
|
+
bus = new ConversationBus();
|
|
2774
|
+
buses.set(key, bus);
|
|
2775
|
+
}
|
|
2776
|
+
return bus;
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Per-conversation turn FIFO: POST /chat enqueues and returns 202, the
|
|
2780
|
+
* detached runner drains one send() at a time (providers reject concurrent
|
|
2781
|
+
* turns on one session). Runners publish their own failures to the bus, so
|
|
2782
|
+
* a queued promise never rejects into the void.
|
|
2783
|
+
*/
|
|
2784
|
+
const turnQueues = /* @__PURE__ */ new Map();
|
|
2785
|
+
function enqueueTurn(key, run) {
|
|
2786
|
+
const next = (turnQueues.get(key) ?? Promise.resolve()).then(run);
|
|
2787
|
+
turnQueues.set(key, next.catch(() => void 0));
|
|
2788
|
+
}
|
|
2218
2789
|
const scheduler = new Scheduler(store, getApp);
|
|
2219
2790
|
/** Rebuilds the request with `prefix` stripped and hands it to the app's handler. */
|
|
2220
2791
|
function dispatch(handler, raw, prefix) {
|
|
@@ -2329,6 +2900,22 @@ ${rows}
|
|
|
2329
2900
|
</tbody>
|
|
2330
2901
|
<tfoot><tr><td colspan="4" class="muted" title="${escapeHtml(TODAY_COLUMN_TITLE)}">AI spend today (UTC)</td><td class="muted" style="text-align:right">${totalToday}</td><td></td></tr></tfoot>
|
|
2331
2902
|
</table>`;
|
|
2903
|
+
const pendingApprovals = approvals.pending();
|
|
2904
|
+
const approvalsSection = pendingApprovals.length === 0 ? "" : `<h2>Pending tool approvals</h2>
|
|
2905
|
+
<div class="panel">
|
|
2906
|
+
<table>
|
|
2907
|
+
<thead><tr><th>App</th><th>Tool</th><th>Requested</th><th>Expires</th><th>Where</th></tr></thead>
|
|
2908
|
+
<tbody>
|
|
2909
|
+
${pendingApprovals.map((approval) => `<tr>
|
|
2910
|
+
<td><a href="/app/${escapeHtml(approval.app)}/">${escapeHtml(approval.app)}</a></td>
|
|
2911
|
+
<td><code>${escapeHtml(approval.tool)}</code></td>
|
|
2912
|
+
<td class="muted">${escapeHtml(approval.requestedAt)}</td>
|
|
2913
|
+
<td class="muted">${escapeHtml(approval.expiresAt)}</td>
|
|
2914
|
+
<td class="muted">${approval.conversationId ? "chat" : "background"}</td>
|
|
2915
|
+
</tr>`).join("\n")}
|
|
2916
|
+
</tbody>
|
|
2917
|
+
</table>
|
|
2918
|
+
</div>`;
|
|
2332
2919
|
return c.html(`<!doctype html>
|
|
2333
2920
|
<html lang="en">
|
|
2334
2921
|
<head>
|
|
@@ -2345,6 +2932,7 @@ ${rows}
|
|
|
2345
2932
|
<div class="panel">
|
|
2346
2933
|
${body}
|
|
2347
2934
|
</div>
|
|
2935
|
+
${approvalsSection}
|
|
2348
2936
|
</main>
|
|
2349
2937
|
</body>
|
|
2350
2938
|
</html>
|
|
@@ -2390,6 +2978,33 @@ ${body}
|
|
|
2390
2978
|
budget: budgetStatusFor(summary, appBudget(config, name))
|
|
2391
2979
|
});
|
|
2392
2980
|
});
|
|
2981
|
+
/**
|
|
2982
|
+
* The per-turn ledger the usage summary is a fold over: one entry per metered
|
|
2983
|
+
* turn, oldest first, carrying the session attribution the day/month buckets
|
|
2984
|
+
* throw away. `?since=` and `?session=` are optional and ANDed.
|
|
2985
|
+
*
|
|
2986
|
+
* `since` is normalised to a canonical ISO instant before it reaches the
|
|
2987
|
+
* recorder, whose bound is compared lexically against the stored `at`. That
|
|
2988
|
+
* keeps a bare `2026-03-02` working as "from that day on" while making an
|
|
2989
|
+
* offset-bearing timestamp (`...T12:00+05:30`) mean the instant it names
|
|
2990
|
+
* rather than whatever its digits happen to sort next to.
|
|
2991
|
+
*/
|
|
2992
|
+
app.get("/api/apps/:name/turns", async (c) => {
|
|
2993
|
+
const name = c.req.param("name");
|
|
2994
|
+
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
2995
|
+
const rawSince = c.req.query("since");
|
|
2996
|
+
let since;
|
|
2997
|
+
if (rawSince !== void 0) {
|
|
2998
|
+
const parsed = Date.parse(rawSince);
|
|
2999
|
+
if (Number.isNaN(parsed)) return c.json({ error: `"since" must be a timestamp, got "${rawSince}"` }, 400);
|
|
3000
|
+
since = new Date(parsed).toISOString();
|
|
3001
|
+
}
|
|
3002
|
+
const sessionId = c.req.query("session");
|
|
3003
|
+
return c.json({ turns: await usage.turns(name, {
|
|
3004
|
+
...since !== void 0 && { since },
|
|
3005
|
+
...sessionId !== void 0 && { sessionId }
|
|
3006
|
+
}) });
|
|
3007
|
+
});
|
|
2393
3008
|
app.get("/api/apps/:name/budget", async (c) => {
|
|
2394
3009
|
const name = c.req.param("name");
|
|
2395
3010
|
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
@@ -2611,8 +3226,12 @@ ${body}
|
|
|
2611
3226
|
} catch (err) {
|
|
2612
3227
|
return c.json({ error: `Failed to start AI session: ${String(err)}` }, 502);
|
|
2613
3228
|
}
|
|
2614
|
-
|
|
2615
|
-
|
|
3229
|
+
const bus = getBus(name, conversation.id);
|
|
3230
|
+
bus.publish({
|
|
3231
|
+
type: "user_message",
|
|
3232
|
+
text: message
|
|
3233
|
+
});
|
|
3234
|
+
enqueueTurn(`${name}/${conversation.id}`, async () => {
|
|
2616
3235
|
try {
|
|
2617
3236
|
for await (const event of session.send(message)) {
|
|
2618
3237
|
if (event.type === "session") {
|
|
@@ -2620,23 +3239,105 @@ ${body}
|
|
|
2620
3239
|
conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2621
3240
|
await store.writeChatState(name, state);
|
|
2622
3241
|
}
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
data: JSON.stringify(event)
|
|
2626
|
-
});
|
|
3242
|
+
if (event.type === "approval_required") approvals.annotate(event.approvalId, { conversationId: conversation.id });
|
|
3243
|
+
bus.publish(event);
|
|
2627
3244
|
}
|
|
2628
3245
|
} catch (err) {
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
ok: false,
|
|
2634
|
-
error: String(err)
|
|
2635
|
-
})
|
|
3246
|
+
bus.publish({
|
|
3247
|
+
type: "result",
|
|
3248
|
+
ok: false,
|
|
3249
|
+
error: String(err)
|
|
2636
3250
|
});
|
|
2637
3251
|
}
|
|
2638
3252
|
});
|
|
3253
|
+
c.header("x-conversation-id", conversation.id);
|
|
3254
|
+
return c.json({ conversationId: conversation.id }, 202);
|
|
3255
|
+
});
|
|
3256
|
+
app.get("/api/apps/:name/chat/:conversationId/events", async (c) => {
|
|
3257
|
+
const name = c.req.param("name");
|
|
3258
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3259
|
+
const conversationId = c.req.param("conversationId");
|
|
3260
|
+
const { conversations } = await getChatState(name);
|
|
3261
|
+
if (!conversations.some((conv) => conv.id === conversationId)) return c.json({ error: "Unknown conversation" }, 404);
|
|
3262
|
+
const raw = c.req.header("last-event-id") ?? c.req.query("lastEventId");
|
|
3263
|
+
const lastEventId = raw !== void 0 && /^\d+$/.test(raw) ? Number(raw) : void 0;
|
|
3264
|
+
const bus = getBus(name, conversationId);
|
|
3265
|
+
const signal = c.req.raw.signal;
|
|
3266
|
+
return streamSSE(c, async (stream) => {
|
|
3267
|
+
for await (const entry of bus.subscribe(lastEventId, signal)) await stream.writeSSE({
|
|
3268
|
+
id: String(entry.id),
|
|
3269
|
+
data: JSON.stringify(entry.event)
|
|
3270
|
+
});
|
|
3271
|
+
});
|
|
3272
|
+
});
|
|
3273
|
+
app.get("/api/apps/:name/chat/:conversationId/pending-approvals", async (c) => {
|
|
3274
|
+
const name = c.req.param("name");
|
|
3275
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3276
|
+
return c.json({ approvals: approvals.pending({
|
|
3277
|
+
app: name,
|
|
3278
|
+
conversationId: c.req.param("conversationId")
|
|
3279
|
+
}) });
|
|
3280
|
+
});
|
|
3281
|
+
/** Parses an approval decision body; returns undefined (with a 400 sent) when invalid. */
|
|
3282
|
+
function parseDecision(body) {
|
|
3283
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
|
|
3284
|
+
const { behavior, scope, message } = body;
|
|
3285
|
+
if (behavior === "allow") {
|
|
3286
|
+
if (scope !== void 0 && scope !== "once" && scope !== "session" && scope !== "always") return;
|
|
3287
|
+
return {
|
|
3288
|
+
behavior: "allow",
|
|
3289
|
+
...scope !== void 0 && { scope }
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
if (behavior === "deny") {
|
|
3293
|
+
if (message !== void 0 && typeof message !== "string") return void 0;
|
|
3294
|
+
return {
|
|
3295
|
+
behavior: "deny",
|
|
3296
|
+
...message !== void 0 && { message },
|
|
3297
|
+
reason: "user"
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Decides one pending approval. `expect` narrows which records this route
|
|
3303
|
+
* may touch (the conversation-scoped route cannot decide another app's or
|
|
3304
|
+
* conversation's approvals).
|
|
3305
|
+
*/
|
|
3306
|
+
async function decideApproval(c, approvalId, expect) {
|
|
3307
|
+
let body;
|
|
3308
|
+
try {
|
|
3309
|
+
body = await c.req.json();
|
|
3310
|
+
} catch {
|
|
3311
|
+
return c.json({ error: "Body must be JSON" }, 400);
|
|
3312
|
+
}
|
|
3313
|
+
const decision = parseDecision(body);
|
|
3314
|
+
if (!decision) return c.json({ error: "Body must be { \"behavior\": \"allow\"|\"deny\", \"scope\"?, \"message\"? }" }, 400);
|
|
3315
|
+
const record = approvals.get(approvalId);
|
|
3316
|
+
if (!record || expect !== void 0 && (record.app !== expect.app || expect.conversationId !== void 0 && record.conversationId !== expect.conversationId)) return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
|
|
3317
|
+
if (decision.behavior === "allow" && decision.scope === "always") {
|
|
3318
|
+
const running = await getApp(record.app);
|
|
3319
|
+
if (running) try {
|
|
3320
|
+
await approvals.addGrant(record.app, record.tool, running.manifest.permissions.askTools);
|
|
3321
|
+
} catch (err) {
|
|
3322
|
+
return c.json({ error: String(err) }, 400);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
const outcome = approvals.respond(approvalId, decision);
|
|
3326
|
+
if (outcome === "unknown") return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
|
|
3327
|
+
if (outcome === "resolved") return c.json({ error: `Approval "${approvalId}" is already decided` }, 409);
|
|
3328
|
+
return c.json({ approval: approvals.get(approvalId) });
|
|
3329
|
+
}
|
|
3330
|
+
app.post("/api/apps/:name/chat/:conversationId/approvals/:approvalId", async (c) => {
|
|
3331
|
+
const name = c.req.param("name");
|
|
3332
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3333
|
+
return decideApproval(c, c.req.param("approvalId"), {
|
|
3334
|
+
app: name,
|
|
3335
|
+
conversationId: c.req.param("conversationId")
|
|
3336
|
+
});
|
|
2639
3337
|
});
|
|
3338
|
+
/** Host-wide views: cron/app-route approvals have no conversation to scope by. */
|
|
3339
|
+
app.get("/api/approvals", (c) => c.json({ approvals: approvals.pending() }));
|
|
3340
|
+
app.post("/api/approvals/:approvalId", (c) => decideApproval(c, c.req.param("approvalId")));
|
|
2640
3341
|
app.all("/app/:name/api/*", async (c) => {
|
|
2641
3342
|
const name = c.req.param("name");
|
|
2642
3343
|
const running = await getApp(name);
|
|
@@ -2668,6 +3369,7 @@ ${body}
|
|
|
2668
3369
|
auth,
|
|
2669
3370
|
async close() {
|
|
2670
3371
|
scheduler.stop();
|
|
3372
|
+
approvals.close();
|
|
2671
3373
|
const open = [...sessions.values()];
|
|
2672
3374
|
sessions.clear();
|
|
2673
3375
|
await Promise.all(open.map(async (p) => {
|
|
@@ -2739,9 +3441,18 @@ var Store = class {
|
|
|
2739
3441
|
appJobStatePath(name) {
|
|
2740
3442
|
return join(this.appDir(name), "jobs.json");
|
|
2741
3443
|
}
|
|
3444
|
+
appApprovalLogPath(name) {
|
|
3445
|
+
return join(this.appDir(name), "approvals.json");
|
|
3446
|
+
}
|
|
3447
|
+
appGrantsPath(name) {
|
|
3448
|
+
return join(this.appDir(name), "grants.json");
|
|
3449
|
+
}
|
|
2742
3450
|
appUsagePath(name) {
|
|
2743
3451
|
return join(this.appDir(name), "usage.json");
|
|
2744
3452
|
}
|
|
3453
|
+
appTurnsPath(name) {
|
|
3454
|
+
return join(this.appDir(name), "turns.jsonl");
|
|
3455
|
+
}
|
|
2745
3456
|
registryCachePath(name) {
|
|
2746
3457
|
if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
|
|
2747
3458
|
return join(this.registriesDir, `${name}.json`);
|
|
@@ -2770,6 +3481,18 @@ var Store = class {
|
|
|
2770
3481
|
async writeChatState(name, state) {
|
|
2771
3482
|
await writeJson(this.appChatStatePath(name), state);
|
|
2772
3483
|
}
|
|
3484
|
+
async readApprovalLog(name) {
|
|
3485
|
+
return { approvals: (await readJson(this.appApprovalLogPath(name)))?.approvals ?? [] };
|
|
3486
|
+
}
|
|
3487
|
+
async writeApprovalLog(name, log) {
|
|
3488
|
+
await writeJson(this.appApprovalLogPath(name), log);
|
|
3489
|
+
}
|
|
3490
|
+
async readGrants(name) {
|
|
3491
|
+
return { tools: (await readJson(this.appGrantsPath(name)))?.tools ?? [] };
|
|
3492
|
+
}
|
|
3493
|
+
async writeGrants(name, state) {
|
|
3494
|
+
await writeJson(this.appGrantsPath(name), state);
|
|
3495
|
+
}
|
|
2773
3496
|
async readJobState(name) {
|
|
2774
3497
|
const state = await readJson(this.appJobStatePath(name));
|
|
2775
3498
|
return {
|
|
@@ -2790,6 +3513,51 @@ var Store = class {
|
|
|
2790
3513
|
async writeUsageState(name, state) {
|
|
2791
3514
|
await writeJson(this.appUsagePath(name), state);
|
|
2792
3515
|
}
|
|
3516
|
+
/** Appends one line to the app's turn ledger, creating the container if needed. */
|
|
3517
|
+
async appendTurnRecord(name, record) {
|
|
3518
|
+
const path = this.appTurnsPath(name);
|
|
3519
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3520
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Every parseable line of the app's turn ledger, in file order (oldest
|
|
3524
|
+
* first); an absent file reads as no turns.
|
|
3525
|
+
*
|
|
3526
|
+
* An append-only log written across crashes and hand edits will eventually
|
|
3527
|
+
* contain a truncated or malformed line. One bad line must cost only itself,
|
|
3528
|
+
* so unparseable lines — and lines without an `at` timestamp, which nothing
|
|
3529
|
+
* downstream could place in time — are skipped rather than thrown on.
|
|
3530
|
+
*/
|
|
3531
|
+
async readTurnRecords(name) {
|
|
3532
|
+
let content;
|
|
3533
|
+
try {
|
|
3534
|
+
content = await readFile(this.appTurnsPath(name), "utf8");
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
if (err.code === "ENOENT") return [];
|
|
3537
|
+
throw err;
|
|
3538
|
+
}
|
|
3539
|
+
const records = [];
|
|
3540
|
+
for (const line of content.split("\n")) {
|
|
3541
|
+
if (line.trim() === "") continue;
|
|
3542
|
+
let parsed;
|
|
3543
|
+
try {
|
|
3544
|
+
parsed = JSON.parse(line);
|
|
3545
|
+
} catch {
|
|
3546
|
+
continue;
|
|
3547
|
+
}
|
|
3548
|
+
const record = parsed;
|
|
3549
|
+
if (typeof record?.at === "string") records.push(record);
|
|
3550
|
+
}
|
|
3551
|
+
return records;
|
|
3552
|
+
}
|
|
3553
|
+
/** Replaces the app's turn ledger atomically (tmp file + rename), for compaction. */
|
|
3554
|
+
async writeTurnRecords(name, records) {
|
|
3555
|
+
const path = this.appTurnsPath(name);
|
|
3556
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3557
|
+
const tmp = `${path}.tmp`;
|
|
3558
|
+
await writeFile(tmp, records.map((record) => `${JSON.stringify(record)}\n`).join(""), "utf8");
|
|
3559
|
+
await rename(tmp, path);
|
|
3560
|
+
}
|
|
2793
3561
|
get authStatePath() {
|
|
2794
3562
|
return join(this.root, "auth.json");
|
|
2795
3563
|
}
|
|
@@ -2823,4 +3591,4 @@ var Store = class {
|
|
|
2823
3591
|
//#endregion
|
|
2824
3592
|
export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, 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 };
|
|
2825
3593
|
|
|
2826
|
-
//# sourceMappingURL=store-
|
|
3594
|
+
//# sourceMappingURL=store-dipRcfKX.mjs.map
|