feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+18
-53
@@ -15,9 +15,9 @@
|
||||
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
|
||||
* window goes last (still used when every account is that full);
|
||||
* 2. fewest live panes (spreads concurrent sessions);
|
||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
|
||||
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
|
||||
* started.
|
||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
|
||||
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
||||
* held them (pool-usage.js).
|
||||
* The quota is read with the account's own token in the background, at most
|
||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||
* reading, and ranking falls back to spend.
|
||||
@@ -375,47 +375,8 @@ function quotaPercent(name) {
|
||||
|
||||
// ── What AgentHub recorded per account ─────────────────────────────────────
|
||||
|
||||
const USAGE_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
"long_input_tokens",
|
||||
"long_output_tokens",
|
||||
"long_cache_read_tokens",
|
||||
"long_cache_write_tokens",
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
/**
|
||||
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
|
||||
* usage of every session one of its runs started, from that run's start hour.
|
||||
* ponytail: a session later resumed on ANOTHER account keeps counting here
|
||||
* too; split by run end time if that ever skews a ranking.
|
||||
*/
|
||||
function spendSince(account, sinceHour) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`WITH s AS (
|
||||
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
|
||||
GROUP BY session_id)
|
||||
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM s JOIN token_usage_hourly h
|
||||
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
|
||||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(account, sinceHour);
|
||||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||||
}
|
||||
/** $ and tokens of the last 5 h / 7 d (pool-usage.js). */
|
||||
const usageOf = (account) => require("./pool-usage").accountUsage("claude", account);
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
@@ -426,8 +387,6 @@ function livePanes(account) {
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
|
||||
|
||||
/** Every account with what the balancer sees — never a token. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => {
|
||||
@@ -442,8 +401,7 @@ function poolStatus() {
|
||||
expiresAt: expiresAt(a),
|
||||
expired: expired(a),
|
||||
livePanes: livePanes(a.name),
|
||||
spend5h: spendSince(a.name, hourAgo(5)),
|
||||
spend7d: spendSince(a.name, hourAgo(24 * 7)),
|
||||
...usageOf(a.name),
|
||||
quota: q
|
||||
? {
|
||||
fiveHour: q.fiveHour || null,
|
||||
@@ -481,8 +439,11 @@ function pick(member) {
|
||||
let usable = allAccounts().filter((a) => !expired(a));
|
||||
if (usable.length === 0) return null;
|
||||
if (!member) {
|
||||
// A host pane's home holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "token");
|
||||
// A host pane's home holds the host's own login — a pool account only when
|
||||
// it was moved in from there (`hostHolds`) and is still the pair on disk:
|
||||
// the pane then runs on it as it is, and its usage is that account's.
|
||||
const disk = homeCreds(HOST_ACCT);
|
||||
usable = usable.filter((a) => a.kind === "token" || ownsHome(a, HOST, disk));
|
||||
} else {
|
||||
const disk = homeCreds(member);
|
||||
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
|
||||
@@ -501,13 +462,14 @@ function pick(member) {
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
const u = usageOf(a.name);
|
||||
return {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
spendSince(a.name, hourAgo(5)),
|
||||
spendSince(a.name, hourAgo(24 * 7)),
|
||||
u.spend5h,
|
||||
u.spend7d,
|
||||
],
|
||||
};
|
||||
})
|
||||
@@ -524,7 +486,8 @@ function pick(member) {
|
||||
* The account a new Claude pane should run on — `{name, kind, token?}` — or
|
||||
* null when the pool has nothing this pane may use. Synchronous (a spawn
|
||||
* cannot wait on the network): it ranks on the cached quota and starts a
|
||||
* background re-read. A `login` is only ever offered for a member.
|
||||
* background re-read. A `login` is offered for a member, and for a host pane
|
||||
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
||||
*/
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
@@ -545,6 +508,8 @@ function forSpawn(member = null) {
|
||||
if (a.kind === "token") {
|
||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||
}
|
||||
// The host's own login is already in place; the server keeps it fresh.
|
||||
if (!member) return { name: a.name, env: {}, place: () => {} };
|
||||
return {
|
||||
name: a.name,
|
||||
env: {},
|
||||
|
||||
Reference in New Issue
Block a user