Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 | |||
| 4a82790734 | |||
| 80000793f4 | |||
| 39bea78a9c | |||
| 364fe4a286 | |||
| 38ea78b489 | |||
| 7b5abc6ad9 | |||
| ca55ff8ec5 | |||
| 0841d3e5c8 | |||
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 |
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
@@ -36,11 +40,8 @@
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
"Bash(gh pr *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ graph TD
|
||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
|
||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file HtmlFrame.tsx
|
||||
* @description An HTML file rendered as a page, the one way every file view
|
||||
* shows one (a Chat file preview, the Changes panel's Preview, the File
|
||||
* Explorer's viewer): its relative CSS, scripts and images pulled in through
|
||||
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
|
||||
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
|
||||
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
|
||||
* file itself through the same `fetchFile`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
title,
|
||||
className = "",
|
||||
testId = "html-frame",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
/** Reads the page's relative files; none = the page as it is. */
|
||||
fetchFile?: FetchFile | null;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// The page shown stays until its successor is ready (a version step, a
|
||||
// re-read): no flash of the spinner between two renders of one file.
|
||||
setFailed(false);
|
||||
(async () => {
|
||||
let text = html;
|
||||
if (text === undefined) {
|
||||
if (!fetchFile) throw new Error("nothing to read the page with");
|
||||
const b = await fetchFile(path);
|
||||
if (b.size > HTML_MAX) throw new Error("too big");
|
||||
text = await blobText(b);
|
||||
}
|
||||
const page = text;
|
||||
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
|
||||
})().then(
|
||||
(d) => live && setDoc(d),
|
||||
() => live && setFailed(true)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [html, path, fetchFile]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (doc === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
srcDoc={artifactDocument("html", doc, false) ?? ""}
|
||||
sandbox={frameSandbox("html")}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
|
||||
export function fetchVia(url: (relPath: string) => string): FetchFile {
|
||||
return async (rel) => {
|
||||
const r = await fetch(url(rel), { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.blob();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file HtmlViewerModal.tsx
|
||||
* @description An HTML file shown as the page in a modal over the whole
|
||||
* screen — the one viewer the File Explorer and the Changes panel's Preview
|
||||
* open (the Chat's file preview opens its own panel as a modal instead, for
|
||||
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
|
||||
* artifact's sandbox, its relative files read through the caller's guarded
|
||||
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
|
||||
* underneath (the explorer's Delete) never fires from inside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { HtmlFrame } from "./HtmlFrame";
|
||||
import type { FetchFile } from "../lib/htmlInline";
|
||||
|
||||
export function HtmlViewerModal({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
onDownload,
|
||||
onClose,
|
||||
testId = "html-viewer",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from; also its title. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
fetchFile?: FetchFile | null;
|
||||
onDownload?: () => void;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
data-testid={testId}
|
||||
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{onDownload && (
|
||||
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -369,6 +372,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -414,6 +419,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -524,6 +533,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||
* (a live one's pane stopped first),
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -69,6 +69,7 @@ const stats: HostStats = {
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
@@ -90,6 +91,8 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
@@ -166,6 +169,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
||||
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
||||
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
|
||||
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
||||
const CODE = new Set([
|
||||
@@ -498,8 +502,11 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
|
||||
const [viewing, setViewing] = useState<FsEntry | null>(null);
|
||||
const openEntry = (e: FsEntry) => {
|
||||
if (isFolder(e)) go(e.path);
|
||||
else if (isHtml(e)) setViewing(e);
|
||||
else download([e]);
|
||||
};
|
||||
const goUp = () => {
|
||||
@@ -970,6 +977,14 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{viewing && (
|
||||
<HtmlViewer
|
||||
entry={viewing}
|
||||
place={target}
|
||||
onDownload={() => download([viewing])}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
@@ -1656,13 +1671,18 @@ export function FileBrowser({
|
||||
{menuItem ? (
|
||||
<>
|
||||
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
||||
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
</MenuItem>
|
||||
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
||||
<MenuItem onSelect={close(() => download(menuTargets))}>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>
|
||||
{t("fx.download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
||||
@@ -1916,6 +1936,36 @@ export function FileExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||
* its relative CSS, scripts and images read through the same download route. */
|
||||
function HtmlViewer({
|
||||
entry,
|
||||
place,
|
||||
onDownload,
|
||||
onClose,
|
||||
}: {
|
||||
entry: FsEntry;
|
||||
place: FsPlace;
|
||||
onDownload: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const placeKey = JSON.stringify(place);
|
||||
const fetchFile = useMemo(
|
||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeKey]
|
||||
);
|
||||
return (
|
||||
<HtmlViewerModal
|
||||
path={entry.path}
|
||||
fetchFile={fetchFile}
|
||||
onDownload={onDownload}
|
||||
onClose={onClose}
|
||||
testId="fx-html-viewer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
* side, each block with the diff's Undo / Keep while nothing
|
||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* whole markdown file can also be read rendered (Preview), an HTML
|
||||
* file's Preview beside Whole file opens the page in a modal
|
||||
* (`HtmlViewerModal`, its relative CSS, scripts and images read
|
||||
* like downloads), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
@@ -77,7 +80,16 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -127,6 +139,8 @@ import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
@@ -185,6 +199,9 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
@@ -874,10 +891,13 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
// A whole markdown file shown rendered instead of as source; per file,
|
||||
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
|
||||
const [htmlOpen, setHtmlOpen] = useState(false);
|
||||
useEffect(() => setHtmlOpen(false), [openFile, mode]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -2044,6 +2064,14 @@ export function LaneChangesPanel({
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() =>
|
||||
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2560,6 +2588,31 @@ export function LaneChangesPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* An HTML file opens as the page in a modal over the screen;
|
||||
a commit's page once its text is read (Whole file). */}
|
||||
{mode === "file" && isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
disabled={dirty || (range === "commit" && !text)}
|
||||
onClick={() => setHtmlOpen(true)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{htmlOpen && openFile && (
|
||||
<HtmlViewerModal
|
||||
path={openFile}
|
||||
// A commit's page is its text at that commit (no sibling
|
||||
// files); a working one is read whole from the disk.
|
||||
html={range === "commit" ? text?.lines.join("\n") : undefined}
|
||||
fetchFile={htmlFetch}
|
||||
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
|
||||
onClose={() => setHtmlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2969,6 +3022,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.annotate")}
|
||||
</button>
|
||||
)}
|
||||
{/* An HTML file's Preview sits beside Whole file instead. */}
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* fetch, pull, push, switch branch (a local one, or one only a remote has —
|
||||
* listed under "On the remote" and checked out as a new tracking branch; the
|
||||
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
@@ -114,6 +116,8 @@ export function LaneGitBar({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
// Fetched remote branches with no local one yet (`origin/feature`).
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@@ -138,6 +142,7 @@ export function LaneGitBar({
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
@@ -152,6 +157,9 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -184,6 +192,9 @@ export function LaneGitBar({
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
@@ -217,6 +228,7 @@ export function LaneGitBar({
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setRemoteBranches(r.remotes || []);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
@@ -446,6 +458,15 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
@@ -476,7 +497,7 @@ export function LaneGitBar({
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -486,7 +507,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -307,30 +308,30 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const run = liveRunFor(lane, liveRuns, s.id);
|
||||
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||
// archiving only its row left a pane running that no list showed.
|
||||
if (run) {
|
||||
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||
await patchSession(s, { archived: true });
|
||||
return;
|
||||
}
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
wt
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
if (wt) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
@@ -32,7 +32,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||
// left a pane running that no list showed any more.
|
||||
const run = runFor(s);
|
||||
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home).
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||
});
|
||||
|
||||
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
|
||||
vi.mocked(api.files.list).mockResolvedValue({
|
||||
...root,
|
||||
entries: [
|
||||
...root.entries,
|
||||
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
|
||||
],
|
||||
});
|
||||
const body: Record<string, string> = {
|
||||
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
|
||||
"/dl/1/s.css": "p{color:red}",
|
||||
};
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
open();
|
||||
await screen.findByText("deck.html");
|
||||
fireEvent.doubleClick(row("deck.html"));
|
||||
const viewer = await screen.findByTestId("fx-html-viewer");
|
||||
await waitFor(() =>
|
||||
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
|
||||
"<style>p{color:red}</style>"
|
||||
)
|
||||
);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
|
||||
fetchSpy.mockRestore();
|
||||
click.mockRestore();
|
||||
});
|
||||
|
||||
it("makes a folder inline", async () => {
|
||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -544,6 +544,71 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "deck.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["<p>deck</p>"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("deck.html"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
const frame = await within(modal).findByTestId("html-frame");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
|
||||
// The source stayed where it was, under the modal.
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await user.click(await screen.findByTitle("deck.html"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* refuses it too), that branches only the remote has are offered (and the list
|
||||
* is re-read after a fetch), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -236,6 +237,43 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
remotes: ["origin/feat/new"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
|
||||
);
|
||||
|
||||
vi.mocked(api.lanes.gitBranches).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* text file it read, wrote or named (a generated report) anywhere else through
|
||||
* `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
|
||||
* a modal over the whole screen.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
@@ -39,6 +41,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -85,6 +88,18 @@ export const extOf = (p: string) => {
|
||||
export const displayName = (p: string) =>
|
||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||
|
||||
/** A file inside the pane's folder: the git target that reads it and its path there. */
|
||||
function workspaceFile(path: string, scope: PreviewScope) {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
return { target, rel: file.slice(base.length + 1) };
|
||||
}
|
||||
|
||||
type Content =
|
||||
| { kind: "loading" }
|
||||
| { kind: "image"; src: string }
|
||||
@@ -121,8 +136,8 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
const inside = workspaceFile(path, scope);
|
||||
if (!inside) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
@@ -147,11 +162,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
const { target, rel } = inside;
|
||||
if (IMAGE_RE.test(path)) {
|
||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||
return;
|
||||
@@ -235,9 +246,28 @@ function defaultView(path: string): View {
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
function Body({
|
||||
path,
|
||||
content,
|
||||
view,
|
||||
scope,
|
||||
}: {
|
||||
path: string;
|
||||
content: Content;
|
||||
view: View;
|
||||
scope: PreviewScope;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
// A page in the workspace brings its relative CSS, scripts and images along.
|
||||
const inside = kind === "html" ? workspaceFile(path, scope) : null;
|
||||
const insideTarget = inside ? JSON.stringify(inside.target) : null;
|
||||
const page = useMemo(() => {
|
||||
if (!inside) return null;
|
||||
const { target, rel } = inside;
|
||||
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [insideTarget, inside?.rel]);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const shown: Content =
|
||||
@@ -290,6 +320,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
if (page && !shown.truncated) {
|
||||
return (
|
||||
<HtmlFrame
|
||||
key={path}
|
||||
path={page.rel}
|
||||
html={shown.text}
|
||||
fetchFile={page.fetchFile}
|
||||
title={baseName(path)}
|
||||
testId="artifact-frame"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
@@ -317,6 +359,7 @@ export function FilePreview({
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
modal = false,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
@@ -324,12 +367,16 @@ export function FilePreview({
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
|
||||
modal?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(modal, modalRef, onClose);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
@@ -384,7 +431,9 @@ export function FilePreview({
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={
|
||||
full
|
||||
modal
|
||||
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
: full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
@@ -482,16 +531,18 @@ export function FilePreview({
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{!modal && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -503,9 +554,35 @@ export function FilePreview({
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} content={content} view={view} />
|
||||
<Body path={path} content={content} view={view} scope={scope} />
|
||||
</section>
|
||||
);
|
||||
if (modal) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={displayName(path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{panel}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
|
||||
* an Outputs row, a path in a reply — opens instead as the page, the same
|
||||
* preview in a modal over the whole screen. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// An HTML file shown as the page, in a modal over the screen.
|
||||
const [htmlView, setHtmlView] = useState<string | null>(null);
|
||||
const show = useCallback(
|
||||
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
|
||||
[]
|
||||
);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
@@ -757,20 +765,21 @@ export const PaneChat = memo(function PaneChat({
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
show(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
[cwd, show]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
const path = resolveIn(cwd, named);
|
||||
if (/\.html?$/i.test(path)) return show(path);
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
setPreview(path);
|
||||
},
|
||||
[laneId, cwd]
|
||||
[laneId, cwd, show]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
@@ -984,7 +993,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenFile={show}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
@@ -1006,6 +1015,15 @@ export const PaneChat = memo(function PaneChat({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{htmlView && (
|
||||
<FilePreview
|
||||
path={htmlView}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setHtmlView(null)}
|
||||
modal
|
||||
/>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
|
||||
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
|
||||
* fields unminified in its build. Undefined on any other shape. */
|
||||
type AtlasPages = { pages: { version: number }[] };
|
||||
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
|
||||
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
|
||||
?._charAtlas;
|
||||
return Array.isArray(atlas?.pages) ? atlas : undefined;
|
||||
};
|
||||
/** Last version handed out by `restampAtlas`, shared by every pane. */
|
||||
let atlasStamp = 0;
|
||||
/**
|
||||
* Gives every page of the addon's glyph atlas a version no texture slot has
|
||||
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
|
||||
* its canvas on its next draw — what addon-webgl 0.20 does with globally
|
||||
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
|
||||
* pages down a slot can hold a stale page whose counter happens to match, and
|
||||
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
|
||||
* per-glyph increments a page makes before the next restamp. False when the
|
||||
* addon is not the shape this knows.
|
||||
*/
|
||||
const restampAtlas = (addon: WebglAddon): boolean => {
|
||||
const atlas = atlasOf(addon);
|
||||
if (!atlas) return false;
|
||||
for (const page of atlas.pages) {
|
||||
atlasStamp += 2 ** 24;
|
||||
page.version = atlasStamp;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||
const readFlag = (key: string) => {
|
||||
try {
|
||||
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
const hasBox = () =>
|
||||
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
if (hasBox()) fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
|
||||
// with the same font. When it fills up it merges four pages into one
|
||||
// larger page and shifts the rest down, and a GPU texture is re-uploaded
|
||||
// only when its page's `version` differs from the uploaded one — after a
|
||||
// shift those per-page counters can coincide, so a slot keeps a stale
|
||||
// page of another size and every glyph is drawn squeezed or foreign
|
||||
// while the buffer stays correct (fixed upstream only in 0.20 betas).
|
||||
// Any change to the page list therefore restamps every page's version
|
||||
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
|
||||
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
|
||||
// but redraws only this pane, leaving its siblings slicing reused space.
|
||||
// An addon of another shape falls back to toggling the family's
|
||||
// trailing space on the next frame, which makes each renderer re-acquire
|
||||
// its atlas (as onFontLoaded does).
|
||||
let atlasFrame = 0;
|
||||
const rebuildAtlas = () => {
|
||||
if (gpu && restampAtlas(gpu)) return true;
|
||||
if (atlasFrame) return false;
|
||||
// Never inside the render that is rasterizing the merging glyph.
|
||||
atlasFrame = requestAnimationFrame(() => {
|
||||
atlasFrame = 0;
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
|
||||
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
|
||||
// The browser may also drop the textures without a context-loss event
|
||||
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
|
||||
// back, and repaint, since nothing else asks this pane to draw.
|
||||
const onPageShown = () => {
|
||||
if (document.visibilityState === "visible" && rebuildAtlas()) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onPageShown);
|
||||
window.addEventListener("focus", onPageShown);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
if (atlasFrame) cancelAnimationFrame(atlasFrame);
|
||||
atlasMerged?.dispose();
|
||||
atlasGrew?.dispose();
|
||||
document.removeEventListener("visibilitychange", onPageShown);
|
||||
window.removeEventListener("focus", onPageShown);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
|
||||
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows a page as a modal over the screen, closed by Esc", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
|
||||
const dialog = await screen.findByRole("dialog", { name: "page.html" });
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("artifact-expand")).toBeNull();
|
||||
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
@@ -242,7 +253,9 @@ describe("the artifact view", () => {
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ const termState = {
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const refreshMock = vi.fn();
|
||||
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||||
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
dispose: disposeMock,
|
||||
refresh: refreshMock,
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||
const gpu: {
|
||||
last: {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
lose: () => void;
|
||||
merge: () => void;
|
||||
grow: () => void;
|
||||
} | null;
|
||||
} = {
|
||||
last: null,
|
||||
};
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn().mockImplementation(() => {
|
||||
let onLoss = () => {};
|
||||
let onMerge = () => {};
|
||||
let onGrow = () => {};
|
||||
const addon = {
|
||||
__webgl: true,
|
||||
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||||
dispose: vi.fn(),
|
||||
onContextLoss: (fn: () => void) => {
|
||||
onLoss = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||||
onMerge = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||||
onGrow = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
};
|
||||
gpu.last = {
|
||||
dispose: addon.dispose,
|
||||
lose: () => onLoss(),
|
||||
merge: () => onMerge(),
|
||||
grow: () => onGrow(),
|
||||
};
|
||||
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||
return addon;
|
||||
}),
|
||||
}));
|
||||
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
|
||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||
});
|
||||
|
||||
it("restamps every atlas page at once when the page list changes", () => {
|
||||
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||||
// pages a texture slot can keep a stale page whose counter matches. Every
|
||||
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||||
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||||
nextAtlas.pages = pages;
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
gpu.last!.merge();
|
||||
const first = pages.map((p) => p.version);
|
||||
expect(new Set(first).size).toBe(3);
|
||||
expect(first.every((v) => v > 7)).toBe(true);
|
||||
gpu.last!.grow();
|
||||
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||||
true
|
||||
);
|
||||
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||||
// The page coming back re-uploads and repaints this pane.
|
||||
refreshMock.mockClear();
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||||
} finally {
|
||||
nextAtlas.pages = null;
|
||||
raf.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||||
// An addon whose atlas this cannot reach: any option change makes each
|
||||
// renderer re-acquire its atlas and re-upload every page.
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const opts = termOptions as { fontFamily?: string };
|
||||
opts.fontFamily = "JetBrains Mono";
|
||||
gpu.last!.merge();
|
||||
gpu.last!.merge(); // coalesced into one frame
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||||
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||||
raf.mockRestore();
|
||||
});
|
||||
|
||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||
|
||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
const r = await fn();
|
||||
toast.success(typeof success === "function" ? success(r) : success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
(r) =>
|
||||
t(
|
||||
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||
{
|
||||
name: p.label,
|
||||
tool: label,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
|
||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||
/** A role, every user, or the users in no pool group. */
|
||||
@@ -168,7 +169,14 @@ export function MemberPicker({
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<UserAvatar
|
||||
name={u.displayName || u.username}
|
||||
userId={u.id}
|
||||
avatarAt={u.avatarAt}
|
||||
letters={1}
|
||||
className="h-5 w-5 text-[10px]"
|
||||
/>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
|
||||
/** Pool tools with their labels, in display order. */
|
||||
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||
@@ -363,12 +364,13 @@ export function PoolGroups({
|
||||
} ${isDragged(drag) ? "opacity-40" : ""}`}
|
||||
data-testid="pool-member"
|
||||
>
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
|
||||
aria-hidden
|
||||
>
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={id}
|
||||
avatarAt={u?.avatarAt}
|
||||
letters={1}
|
||||
className="h-4 w-4 text-[9px]"
|
||||
/>
|
||||
{name}
|
||||
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
|
||||
{isAdmin && from && (
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
||||
* or when the image fails to load — the first letters of its name. Used by the
|
||||
* sidebar's account row, its workspace owner groups and Settings → Account.
|
||||
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||
* Users page and its editor, the pool board and member picker, and System
|
||||
* Health's per-user CPU/RAM card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* account — and, for an admin, whether they own the host's Linux account, so
|
||||
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* account owns (remove, add from every workspace), its spend quota (the
|
||||
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
|
||||
* "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
@@ -17,16 +20,25 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserPatch,
|
||||
type UserQuota,
|
||||
type UserQuotaSetting,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { PasswordRules } from "./PasswordRules";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
@@ -55,10 +67,13 @@ export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
quotaDefault = null,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The default spend quota, named beside the "Default" choice. */
|
||||
quotaDefault?: UserQuota | null;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
@@ -73,6 +88,7 @@ export function UserEditDialog({
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
@@ -81,6 +97,18 @@ export function UserEditDialog({
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
// Spend quota: absent from an older server, which then gets no quota field.
|
||||
const quotaSetting = user.quotaSetting;
|
||||
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
|
||||
quotaSetting?.mode ?? "default"
|
||||
);
|
||||
const [quotaLimit, setQuotaLimit] = useState(
|
||||
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
|
||||
);
|
||||
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
|
||||
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
|
||||
);
|
||||
const quotaLabel = useQuotaLabel();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -113,16 +141,38 @@ export function UserEditDialog({
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const quotaValue = parseLimit(quotaLimit);
|
||||
const quotaBad = quotaMode === "custom" && quotaValue === null;
|
||||
if (quotaSetting && !quotaBad) {
|
||||
const same =
|
||||
quotaMode === quotaSetting.mode &&
|
||||
(quotaMode !== "custom" ||
|
||||
(quotaSetting.mode === "custom" &&
|
||||
quotaSetting.limit_usd === quotaValue &&
|
||||
quotaSetting.period === quotaPeriod));
|
||||
if (!same) {
|
||||
patch.quota =
|
||||
quotaMode === "custom"
|
||||
? {
|
||||
mode: "custom",
|
||||
limit_usd: quotaValue as number,
|
||||
period: quotaPeriod,
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
}
|
||||
: { mode: quotaMode };
|
||||
}
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -197,12 +247,12 @@ export function UserEditDialog({
|
||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={shown}
|
||||
userId={user.id}
|
||||
avatarAt={user.avatarAt}
|
||||
className="h-10 w-10 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||
{t("edit.title", { name: user.displayName || user.username })}
|
||||
@@ -317,6 +367,17 @@ export function UserEditDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{role === "admin" && (
|
||||
<div className="mt-3">
|
||||
<Checkbox
|
||||
checked={hostAccount}
|
||||
onChange={setHostAccount}
|
||||
label={t("edit.hostAccount")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
@@ -479,6 +540,44 @@ export function UserEditDialog({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{quotaSetting && (
|
||||
<Section title={t("quota.title")}>
|
||||
<div className="space-y-2" data-testid="user-edit-quota">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={quotaMode}
|
||||
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
|
||||
aria-label={t("quota.title")}
|
||||
>
|
||||
<option value="default">
|
||||
{quotaDefault
|
||||
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
|
||||
: t("quota.modes.defaultNone")}
|
||||
</option>
|
||||
<option value="unlimited">{t("quota.modes.unlimited")}</option>
|
||||
<option value="custom">{t("quota.modes.custom")}</option>
|
||||
</select>
|
||||
{quotaMode === "custom" && (
|
||||
<QuotaFields
|
||||
limit={quotaLimit}
|
||||
period={quotaPeriod}
|
||||
onLimit={setQuotaLimit}
|
||||
onPeriod={setQuotaPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
<span className="block text-[11px] text-fg-muted">
|
||||
{user.quota
|
||||
? t("quota.now", {
|
||||
spend: fmtCost(user.quota.spend),
|
||||
limit: quotaLabel(user.quota),
|
||||
})
|
||||
: t("quota.enforceHint")}
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @file UserQuota.tsx
|
||||
* @description Spend quotas per user on the Users page (server mode;
|
||||
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
|
||||
* line with its bar — red once used up, with when it opens again;
|
||||
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
|
||||
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
|
||||
* without one of their own gets — an admin edits it with their own password
|
||||
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
|
||||
* setting is part of the Edit user form (`UserEditDialog`). A user over their
|
||||
* quota can start no session and their prompts are refused until it resets.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Pencil } from "lucide-react";
|
||||
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Meter } from "../health/HostHealth";
|
||||
|
||||
export type QuotaPeriod = UserQuota["period"];
|
||||
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
|
||||
|
||||
/** "$10 / day", "$2.50 / 5 h". */
|
||||
export function useQuotaLabel() {
|
||||
const { t } = useTranslation("users");
|
||||
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
|
||||
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
|
||||
}
|
||||
|
||||
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
|
||||
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
if (quota === undefined) return null;
|
||||
if (quota === null) {
|
||||
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mt-1 space-y-0.5"
|
||||
data-testid="user-quota"
|
||||
title={
|
||||
quota.exceeded && quota.resetsAt
|
||||
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
|
||||
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
|
||||
</div>
|
||||
<Meter value={quota.pct} label={t("quota.title")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dollar limit and its period. `limit` is the raw input text. */
|
||||
export function QuotaFields({
|
||||
limit,
|
||||
period,
|
||||
onLimit,
|
||||
onPeriod,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
}: {
|
||||
limit: string;
|
||||
period: QuotaPeriod;
|
||||
onLimit: (v: string) => void;
|
||||
onPeriod: (v: QuotaPeriod) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-36">
|
||||
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="input w-full pl-6 text-sm tabular-nums"
|
||||
value={limit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onLimit(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("quota.limit")}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input flex-1 text-sm"
|
||||
value={period}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
|
||||
aria-label={t("quota.period")}
|
||||
>
|
||||
{QUOTA_PERIODS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`quota.periods.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A positive dollar amount from the input text, or null. */
|
||||
export function parseLimit(text: string): number | null {
|
||||
const n = Number(text);
|
||||
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The default quota: shown to staff, edited by an admin with their own password. */
|
||||
export function QuotaDefaultCard({
|
||||
quotaDefault,
|
||||
isAdmin,
|
||||
onSaved,
|
||||
}: {
|
||||
/** `undefined` while loading, null for none. */
|
||||
quotaDefault: UserQuota | null | undefined;
|
||||
isAdmin: boolean;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const label = useQuotaLabel();
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<section className="card" data-testid="quota-default-card">
|
||||
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
||||
<Gauge className="h-4 w-4 text-fg-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
|
||||
</div>
|
||||
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
|
||||
{quotaDefault === undefined
|
||||
? "…"
|
||||
: quotaDefault
|
||||
? label(quotaDefault)
|
||||
: t("quota.noDefault")}
|
||||
</div>
|
||||
{isAdmin && quotaDefault !== undefined && (
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
|
||||
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<QuotaDefaultDialog
|
||||
current={quotaDefault ?? null}
|
||||
onClose={() => setEditing(false)}
|
||||
onDone={(message) => {
|
||||
setEditing(false);
|
||||
onSaved(message);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaDefaultDialog({
|
||||
current,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
current: UserQuota | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [on, setOn] = useState(current !== null);
|
||||
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
|
||||
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = parseLimit(limit);
|
||||
const ready = password.length > 0 && (!on || value !== null) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.setQuotaDefault(
|
||||
on && value !== null
|
||||
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
|
||||
: null,
|
||||
password
|
||||
);
|
||||
onDone(t("quota.defaultSaved"));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("quota.defaultDialog")}
|
||||
confirmLabel={t("save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onChange={setOn}
|
||||
label={t("quota.defaultOn")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<QuotaFields
|
||||
limit={limit}
|
||||
period={period}
|
||||
onLimit={setLimit}
|
||||
onPeriod={setPeriod}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={!on}
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
|
||||
updateGroup: vi.fn(),
|
||||
deleteGroup: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
ompSharing: vi.fn(),
|
||||
|
||||
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
|
||||
const users = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
avatarUrl: () => null,
|
||||
}));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file UserQuota.test.tsx
|
||||
* @description Spend quotas on the Users page: a row's "spent of limit" line
|
||||
* (red once used up, "(default)" when it is the default), the default quota
|
||||
* card (an admin edits it with their own password, an analyst only reads),
|
||||
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
setQuotaDefault: vi.fn(),
|
||||
update: vi.fn(),
|
||||
get: vi.fn(),
|
||||
avatarUrl: () => null,
|
||||
}));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
|
||||
import { UserEditDialog } from "../UserEditDialog";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
|
||||
users.update.mockResolvedValue({ user: {} });
|
||||
users.get.mockResolvedValue({ user: {}, workspaces: [] });
|
||||
lanes.list.mockResolvedValue({ lanes: [] });
|
||||
});
|
||||
|
||||
describe("QuotaSummary", () => {
|
||||
it("shows spent of the limit, red and marked default once used up", () => {
|
||||
render(
|
||||
<QuotaSummary
|
||||
quota={{
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
source: "default",
|
||||
spend: 12.5,
|
||||
pct: 125,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByText(/of \$10\.00 \/ day/);
|
||||
expect(line.textContent).toContain("$12.50");
|
||||
expect(line.textContent).toContain("(default)");
|
||||
expect(line.className).toContain("text-status-danger");
|
||||
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
|
||||
});
|
||||
|
||||
it("says No quota without one, and nothing for an older server", () => {
|
||||
const { container, rerender } = render(<QuotaSummary quota={null} />);
|
||||
expect(screen.getByText("No quota")).toBeInTheDocument();
|
||||
rerender(<QuotaSummary quota={undefined} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuotaDefaultCard", () => {
|
||||
it("an admin sets the default with their own password", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
|
||||
await user.click(screen.getByRole("button", { name: /Edit/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "25");
|
||||
await user.selectOptions(screen.getByLabelText("Period"), "5h");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // no password yet
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(save);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(users.setQuotaDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" }),
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("an analyst only reads it", () => {
|
||||
render(
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={{ limit_usd: 5, period: "day" }}
|
||||
isAdmin={false}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
|
||||
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog quota", () => {
|
||||
const mia: ServerUserUsage = {
|
||||
id: 7,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
quotaSetting: { mode: "default" },
|
||||
quota: null,
|
||||
} as ServerUserUsage;
|
||||
|
||||
it("sends the user's own limit in the one PATCH", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<UserEditDialog
|
||||
user={mia}
|
||||
self={false}
|
||||
users={[mia]}
|
||||
quotaDefault={{ limit_usd: 10, period: "day" }}
|
||||
onClose={() => {}}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByLabelText("Spend quota");
|
||||
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
|
||||
await user.selectOptions(select, "custom");
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "40");
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
|
||||
"pw"
|
||||
);
|
||||
});
|
||||
|
||||
it("No limit is a change; an empty custom limit keeps Save off", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
|
||||
);
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.lineElided": "… {{size}} more characters on this line",
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
@@ -195,6 +196,7 @@
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
@@ -504,8 +506,6 @@
|
||||
"changes.loadMore": "Load more commits",
|
||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||
"changes.splitter": "Resize the changes panel",
|
||||
"tree.archiveLiveTitle": "Archive a live session?",
|
||||
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
|
||||
"tree.state.awaiting": "waiting for you",
|
||||
"tree.state.error": "error",
|
||||
"tree.state.live": "live",
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Week",
|
||||
"mo": "Month"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "today",
|
||||
"5h": "in the last 5 hours"
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
|
||||
@@ -1396,6 +1396,7 @@
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password cannot be empty.",
|
||||
"EBADEMAIL": "That email address does not look valid.",
|
||||
"ENAMETAKEN": "Another account already uses that name. Pick one that is yours.",
|
||||
"EBADFIELD": "One of the values is not valid.",
|
||||
"ENOLANE": "That workspace no longer exists.",
|
||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||
@@ -87,7 +88,8 @@
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
"EPWCHANGE": "Choose your own password first.",
|
||||
"EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -110,6 +112,8 @@
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||
"hostAccount": "Owns this server's host account",
|
||||
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
@@ -412,5 +416,37 @@
|
||||
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||
"sessionsN": "{{n}} sessions",
|
||||
"workspacesN": "{{n}} workspaces",
|
||||
"tmuxCol": "tmux"
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Spend quota",
|
||||
"limit": "Limit in dollars",
|
||||
"period": "Period",
|
||||
"periods": {
|
||||
"day": "per day (from local midnight)",
|
||||
"5h": "per 5 hours (sliding)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / day",
|
||||
"5h": "{{limit}} / 5 h"
|
||||
},
|
||||
"used": "{{spend}} of {{limit}}",
|
||||
"defaultTag": "(default)",
|
||||
"none": "No quota",
|
||||
"blockedUntil": "Used up — no new session and no prompt until {{at}}",
|
||||
"cardTitle": "Default spend quota",
|
||||
"cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.",
|
||||
"noDefault": "No default — no limit",
|
||||
"editDefault": "Edit",
|
||||
"defaultDialog": "Default spend quota",
|
||||
"defaultOn": "Limit what each user may spend",
|
||||
"defaultSaved": "Default spend quota saved",
|
||||
"enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.",
|
||||
"modes": {
|
||||
"defaultOf": "Default ({{quota}})",
|
||||
"defaultNone": "Default (no limit)",
|
||||
"unlimited": "No limit",
|
||||
"custom": "Their own limit"
|
||||
},
|
||||
"now": "Now: {{spend}} of {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
@@ -195,6 +196,7 @@
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
@@ -504,8 +506,6 @@
|
||||
"changes.loadMore": "Tải thêm commit",
|
||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
|
||||
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
|
||||
"tree.state.awaiting": "đang chờ bạn",
|
||||
"tree.state.error": "lỗi",
|
||||
"tree.state.live": "đang chạy",
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
"quotaWindow": {
|
||||
"wk": "Tuần",
|
||||
"mo": "Tháng"
|
||||
},
|
||||
"userQuota": "Quota {{pct}}%",
|
||||
"userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}",
|
||||
"userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"userQuotaPeriod": {
|
||||
"day": "hôm nay",
|
||||
"5h": "trong 5 giờ qua"
|
||||
}
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
|
||||
@@ -1384,6 +1384,7 @@
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||
"ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.",
|
||||
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||
@@ -87,7 +88,8 @@
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.",
|
||||
"EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -110,6 +112,8 @@
|
||||
"status": "Trạng thái",
|
||||
"active": "Đang hoạt động",
|
||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||
"hostAccount": "Sở hữu tài khoản host của máy chủ",
|
||||
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
@@ -412,5 +416,37 @@
|
||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||
"sessionsN": "{{n}} session",
|
||||
"workspacesN": "{{n}} workspace",
|
||||
"tmuxCol": "tmux"
|
||||
"tmuxCol": "tmux",
|
||||
"quota": {
|
||||
"title": "Quota chi phí",
|
||||
"limit": "Hạn mức (USD)",
|
||||
"period": "Chu kỳ",
|
||||
"periods": {
|
||||
"day": "mỗi ngày (từ nửa đêm giờ địa phương)",
|
||||
"5h": "mỗi 5 giờ (trượt)"
|
||||
},
|
||||
"per": {
|
||||
"day": "{{limit}} / ngày",
|
||||
"5h": "{{limit}} / 5 giờ"
|
||||
},
|
||||
"used": "{{spend}} trên {{limit}}",
|
||||
"defaultTag": "(mặc định)",
|
||||
"none": "Không giới hạn",
|
||||
"blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}",
|
||||
"cardTitle": "Quota chi phí mặc định",
|
||||
"cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.",
|
||||
"noDefault": "Chưa có mặc định — không giới hạn",
|
||||
"editDefault": "Sửa",
|
||||
"defaultDialog": "Quota chi phí mặc định",
|
||||
"defaultOn": "Giới hạn chi phí của mỗi user",
|
||||
"defaultSaved": "Đã lưu quota chi phí mặc định",
|
||||
"enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.",
|
||||
"modes": {
|
||||
"defaultOf": "Mặc định ({{quota}})",
|
||||
"defaultNone": "Mặc định (không giới hạn)",
|
||||
"unlimited": "Không giới hạn",
|
||||
"custom": "Hạn mức riêng"
|
||||
},
|
||||
"now": "Hiện tại: {{spend}} trên {{limit}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file htmlInline.test.ts
|
||||
* @description An HTML preview's relative CSS, scripts and images are inlined
|
||||
* from the place's own files, never from above its root or off a URL.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { inlineHtml, resolveRef } from "../htmlInline";
|
||||
|
||||
const files: Record<string, string> = {
|
||||
"site/css/app.css": "body{background:url(../img/bg.png)}",
|
||||
"site/js/app.js": "window.ok=1",
|
||||
"site/img/logo.png": "PNG",
|
||||
"site/img/bg.png": "BG",
|
||||
};
|
||||
const fetchFile = vi.fn(async (p: string) => {
|
||||
if (!(p in files)) throw new Error("404");
|
||||
return new Blob([files[p]!]);
|
||||
});
|
||||
|
||||
describe("resolveRef", () => {
|
||||
it("resolves against the page's folder, from the root on a leading slash", () => {
|
||||
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
|
||||
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
|
||||
expect(resolveRef("site", "/top.css")).toBe("top.css");
|
||||
});
|
||||
it("skips URLs, anchors and anything above the root", () => {
|
||||
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
|
||||
expect(resolveRef("site", r)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineHtml", () => {
|
||||
it("inlines stylesheets (with their url()s), scripts and images", async () => {
|
||||
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
|
||||
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
|
||||
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
|
||||
const out = await inlineHtml(html, "site/index.html", fetchFile);
|
||||
expect(out.startsWith("<!doctype html>")).toBe(true);
|
||||
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
|
||||
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
|
||||
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
|
||||
expect(out).not.toContain("srcset");
|
||||
expect(out).toContain('src="https://cdn.example/x.js"');
|
||||
expect(out).toContain('src="missing.png"');
|
||||
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
|
||||
});
|
||||
it("returns the page untouched when nothing is local", async () => {
|
||||
const html = "<p>hi <a href='https://x'>x</a></p>";
|
||||
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
|
||||
});
|
||||
});
|
||||
+47
-2
@@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & {
|
||||
/** Active login sessions (cookies not expired). */
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
/** This user's own spend quota setting (absent from an older server). */
|
||||
quotaSetting?: UserQuotaSetting;
|
||||
/** The quota that binds them now with its spend; null = no limit. */
|
||||
quota?: UserQuotaStatus | null;
|
||||
};
|
||||
/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */
|
||||
export type UserQuota = {
|
||||
limit_usd: number;
|
||||
period: "day" | "5h";
|
||||
/** getTimezoneOffset() minutes of the zone a `day` starts in. */
|
||||
tz_offset?: number;
|
||||
/** Where it comes from: the default, or the user's own. */
|
||||
source?: "default" | "user";
|
||||
};
|
||||
/** A user's own setting: the default applies, no limit, or a limit of their own. */
|
||||
export type UserQuotaSetting =
|
||||
{ mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota);
|
||||
/** A quota now (`GET /api/users`, `GET /api/me/quota`). */
|
||||
export type UserQuotaStatus = UserQuota & {
|
||||
spend: number;
|
||||
pct: number;
|
||||
/** Used up: no new session starts and prompts are refused until `resetsAt`. */
|
||||
exceeded: boolean;
|
||||
resetsAt: string | null;
|
||||
since: string;
|
||||
};
|
||||
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
|
||||
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
||||
@@ -659,6 +684,8 @@ export interface HostUsageGroup {
|
||||
username?: string;
|
||||
/** The user's display name, null when none is set (absent from an older server). */
|
||||
displayName?: string | null;
|
||||
/** When the user's avatar was last set (null = none; absent from an older server). */
|
||||
avatarAt?: string | null;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
@@ -773,6 +800,10 @@ export type UserPatch = {
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
|
||||
hostAccount?: boolean;
|
||||
/** Their own spend quota (`default` = the default applies). */
|
||||
quota?: UserQuotaSetting;
|
||||
};
|
||||
/** GET /api/me/account — the signed-in account's own information. */
|
||||
export type MyAccount =
|
||||
@@ -954,6 +985,8 @@ export type AuthUser = {
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
|
||||
hostAccount?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
@@ -1210,6 +1243,8 @@ export const api = {
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */
|
||||
quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"),
|
||||
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
|
||||
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
||||
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
||||
@@ -1271,7 +1306,15 @@ export const api = {
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
|
||||
`/users?tz_offset=${new Date().getTimezoneOffset()}`
|
||||
),
|
||||
/** PUT /api/users/quota-default — the default spend quota (null = none). */
|
||||
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
|
||||
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ quota, currentPassword }),
|
||||
}),
|
||||
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||
details: (id: number) =>
|
||||
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
@@ -1362,7 +1405,7 @@ export const api = {
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
@@ -4283,6 +4326,8 @@ export const api = {
|
||||
request<{
|
||||
available: boolean;
|
||||
branches: string[];
|
||||
/** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */
|
||||
remotes?: string[];
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @file htmlInline.ts
|
||||
* @description An HTML file made whole for its sandboxed preview. The page
|
||||
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
|
||||
* nothing from the dashboard — and the dashboard's login cookie is
|
||||
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
|
||||
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
|
||||
* the `url(...)`s inside its CSS through a read route that already guards
|
||||
* them (the caller's `fetchFile`: a workspace's or a home's download), and
|
||||
* writes them into the page as `<style>` and `data:` URLs before it renders.
|
||||
* Only paths inside the page's own place are followed (`..` above its root is
|
||||
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
|
||||
* all, so a big site never stalls the viewer. A reference that cannot be read
|
||||
* is left as it was. `fetch()`/XHR from the page's own script, ES module
|
||||
* imports and links to other pages still do not work.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** Reads one file by its path relative to the place's root; throws when it cannot. */
|
||||
export type FetchFile = (relPath: string) => Promise<Blob>;
|
||||
|
||||
const MAX_ASSETS = 60;
|
||||
const MAX_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
css: "text/css",
|
||||
js: "text/javascript",
|
||||
mjs: "text/javascript",
|
||||
json: "application/json",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
svg: "image/svg+xml",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
};
|
||||
|
||||
const mimeOf = (p: string) =>
|
||||
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
|
||||
|
||||
/** The folder part of a relative path ("" at the root). */
|
||||
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
||||
|
||||
/**
|
||||
* A page's reference as a path from the place's root, or null when it is not
|
||||
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
|
||||
* A leading `/` counts from the root, as a site served from it would.
|
||||
*/
|
||||
export function resolveRef(dir: string, ref: string): string | null {
|
||||
const r = ref.trim();
|
||||
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
|
||||
let p = r.replace(/[?#].*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
// A stray % is a literal one.
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
|
||||
if (!seg || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
if (!out.length) return null;
|
||||
out.pop();
|
||||
} else out.push(seg);
|
||||
}
|
||||
return out.length ? out.join("/") : null;
|
||||
}
|
||||
|
||||
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
|
||||
export function blobText(b: Blob): Promise<string> {
|
||||
if (typeof b.text === "function") return b.text();
|
||||
return new Promise((ok, fail) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(String(r.result));
|
||||
r.onerror = () => fail(r.error);
|
||||
r.readAsText(b);
|
||||
});
|
||||
}
|
||||
function blobBytes(b: Blob): Promise<ArrayBuffer> {
|
||||
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
|
||||
return new Promise((ok, fail) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(r.result as ArrayBuffer);
|
||||
r.onerror = () => fail(r.error);
|
||||
r.readAsArrayBuffer(b);
|
||||
});
|
||||
}
|
||||
|
||||
function base64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
|
||||
|
||||
/** Inline the relative resources of `html` (the file at `htmlPath`). */
|
||||
export async function inlineHtml(
|
||||
html: string,
|
||||
htmlPath: string,
|
||||
fetchFile: FetchFile
|
||||
): Promise<string> {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const cache = new Map<string, Promise<Blob | null>>();
|
||||
let count = 0;
|
||||
let bytes = 0;
|
||||
let changed = false;
|
||||
|
||||
const read = (rel: string): Promise<Blob | null> => {
|
||||
let p = cache.get(rel);
|
||||
if (!p) {
|
||||
if (count >= MAX_ASSETS) return Promise.resolve(null);
|
||||
count++;
|
||||
p = fetchFile(rel)
|
||||
.then((b) => {
|
||||
if (bytes + b.size > MAX_BYTES) return null;
|
||||
bytes += b.size;
|
||||
return b;
|
||||
})
|
||||
.catch(() => null);
|
||||
cache.set(rel, p);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
const dataUrl = async (rel: string): Promise<string | null> => {
|
||||
const b = await read(rel);
|
||||
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
|
||||
};
|
||||
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
|
||||
const inlineCss = async (css: string, dir: string): Promise<string> => {
|
||||
const refs = new Map<string, string | null>();
|
||||
for (const m of css.matchAll(URL_RE)) {
|
||||
const rel = resolveRef(dir, m[2]!);
|
||||
if (rel && !refs.has(rel)) refs.set(rel, null);
|
||||
}
|
||||
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
|
||||
return css.replace(URL_RE, (all, _q, ref: string) => {
|
||||
const rel = resolveRef(dir, ref);
|
||||
const url = rel ? refs.get(rel) : null;
|
||||
return url ? `url("${url}")` : all;
|
||||
});
|
||||
};
|
||||
|
||||
const dir = dirOf(htmlPath);
|
||||
const jobs: Promise<void>[] = [];
|
||||
|
||||
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
|
||||
const rel = resolveRef(dir, link.getAttribute("href")!);
|
||||
if (!rel) continue;
|
||||
jobs.push(
|
||||
(async () => {
|
||||
const b = await read(rel);
|
||||
if (!b) return;
|
||||
const style = doc.createElement("style");
|
||||
const media = link.getAttribute("media");
|
||||
if (media) style.setAttribute("media", media);
|
||||
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
|
||||
link.replaceWith(style);
|
||||
changed = true;
|
||||
})()
|
||||
);
|
||||
}
|
||||
for (const style of doc.querySelectorAll("style")) {
|
||||
const css = style.textContent || "";
|
||||
if (!/url\(/i.test(css)) continue;
|
||||
jobs.push(
|
||||
inlineCss(css, dir).then((out) => {
|
||||
if (out !== css) {
|
||||
style.textContent = out;
|
||||
changed = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
|
||||
const css = el.getAttribute("style")!;
|
||||
jobs.push(
|
||||
inlineCss(css, dir).then((out) => {
|
||||
if (out !== css) {
|
||||
el.setAttribute("style", out);
|
||||
changed = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
const attrs: [string, string][] = [
|
||||
["script[src]", "src"],
|
||||
["img[src]", "src"],
|
||||
["source[src]", "src"],
|
||||
["video[src]", "src"],
|
||||
["audio[src]", "src"],
|
||||
["video[poster]", "poster"],
|
||||
["input[type='image' i][src]", "src"],
|
||||
["link[rel~='icon' i][href]", "href"],
|
||||
];
|
||||
for (const [sel, attr] of attrs) {
|
||||
for (const el of doc.querySelectorAll(sel)) {
|
||||
const rel = resolveRef(dir, el.getAttribute(attr)!);
|
||||
if (!rel) continue;
|
||||
jobs.push(
|
||||
dataUrl(rel).then((url) => {
|
||||
if (!url) return;
|
||||
el.setAttribute(attr, url);
|
||||
// A srcset beside it would win over the inlined src and still miss.
|
||||
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
||||
changed = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
if (!changed) return html;
|
||||
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
|
||||
return doctype + doc.documentElement.outerHTML;
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* message. Both ask first when any pane holds an AI agent (it may be
|
||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||
* session's right-click menu (`SessionMenu`).
|
||||
* session's right-click menu (`SessionMenu`), and a live session's Archive
|
||||
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
|
||||
/** Line number on the right (after) side; null on a removed line. */
|
||||
newLine: number | null;
|
||||
text: string;
|
||||
/** Characters cut off the end of a very long row (a base64 blob, a minified
|
||||
* bundle): `text` holds only its start. Absent on a whole row. */
|
||||
elided?: number;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -1629,12 +1630,41 @@ function DetailDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** "N installs" on a card; a click lists who installed it, when, and where. */
|
||||
/** "N installs" on a card; a click lists who installed it, when, and where.
|
||||
* The list floats over the page (a portal at the button, below it or above
|
||||
* when there is no room, kept inside the window): inside the card list's
|
||||
* `overflow-hidden` it was cut off at the last card. */
|
||||
function InstallsButton({ item }: { item: Artifact }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [data, setData] = useState<ArtifactInstalls | null>(null);
|
||||
const box = useRef<HTMLSpanElement>(null);
|
||||
const pop = useRef<HTMLDivElement>(null);
|
||||
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return setAt(null);
|
||||
const place = () => {
|
||||
const b = box.current?.getBoundingClientRect();
|
||||
const p = pop.current;
|
||||
if (!b || !p) return;
|
||||
const w = p.offsetWidth;
|
||||
const h = p.offsetHeight;
|
||||
const gap = 4;
|
||||
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
|
||||
setAt({
|
||||
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
|
||||
top: below ? b.bottom + gap : b.top - gap - h,
|
||||
});
|
||||
};
|
||||
place();
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [open, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -1647,7 +1677,8 @@ function InstallsButton({ item }: { item: Artifact }) {
|
||||
toast.error(err);
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
const n = e.target as Node;
|
||||
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
@@ -1670,66 +1701,70 @@ function InstallsButton({ item }: { item: Artifact }) {
|
||||
>
|
||||
{t("installs", { count: item.installs })}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("installsTitle")}
|
||||
data-testid="artifact-installs"
|
||||
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
||||
>
|
||||
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
||||
{data === null ? (
|
||||
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||
{data.items.map((i, n) => (
|
||||
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
||||
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
||||
{i.replaced && (
|
||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||
{new Date(i.at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{i.paths && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{i.paths.length ? (
|
||||
i.paths.map((p) => (
|
||||
<p
|
||||
key={p}
|
||||
className="truncate font-mono text-[10px] text-fg-muted"
|
||||
title={p}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="font-mono text-[10px] text-fg-muted">
|
||||
{t("installsPluginWhere")}
|
||||
</p>
|
||||
)}
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={pop}
|
||||
role="dialog"
|
||||
aria-label={t("installsTitle")}
|
||||
data-testid="artifact-installs"
|
||||
style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
|
||||
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
||||
>
|
||||
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
||||
{data === null ? (
|
||||
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||
{data.items.map((i, n) => (
|
||||
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
||||
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
||||
{i.replaced && (
|
||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||
{new Date(i.at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{data.unrecorded > 0 && (
|
||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||
{t("installsUnrecorded", { count: data.unrecorded })}
|
||||
</li>
|
||||
)}
|
||||
{data.items.length === 0 && data.unrecorded === 0 && (
|
||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||
{t("installsNoneOfYours")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{i.paths && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{i.paths.length ? (
|
||||
i.paths.map((p) => (
|
||||
<p
|
||||
key={p}
|
||||
className="truncate font-mono text-[10px] text-fg-muted"
|
||||
title={p}
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="font-mono text-[10px] text-fg-muted">
|
||||
{t("installsPluginWhere")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{data.unrecorded > 0 && (
|
||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||
{t("installsUnrecorded", { count: data.unrecorded })}
|
||||
</li>
|
||||
)}
|
||||
{data.items.length === 0 && data.unrecorded === 0 && (
|
||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||
{t("installsNoneOfYours")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`).
|
||||
* on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the
|
||||
* limit, red once used up) and the default quota sits in its own card
|
||||
* (`UserQuota.tsx`); a user's own quota is part of Edit.
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set, and "New password due" while
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type AuthUser,
|
||||
type MemberTmuxRow,
|
||||
type ServerUserUsage,
|
||||
type UserQuota,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
@@ -74,6 +77,7 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { UserDetail } from "../components/users/UserDetail";
|
||||
import { UserAvatar } from "../components/users/UserAvatar";
|
||||
import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota";
|
||||
import {
|
||||
MemberTmuxBar,
|
||||
TmuxCell,
|
||||
@@ -104,6 +108,8 @@ export function Users({
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
// The default spend quota (`undefined` until loaded or from an older server).
|
||||
const [quotaDefault, setQuotaDefault] = useState<UserQuota | null | undefined>(undefined);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -151,6 +157,7 @@ export function Users({
|
||||
.list()
|
||||
.then((r) => {
|
||||
setUsers(r.users);
|
||||
setQuotaDefault(r.quotaDefault);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
@@ -406,6 +413,7 @@ export function Users({
|
||||
month: fmtCost(u.last30d.cost),
|
||||
})}
|
||||
</div>
|
||||
<QuotaSummary quota={u.quota} />
|
||||
</td>
|
||||
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
|
||||
{showTmux && (
|
||||
@@ -487,6 +495,17 @@ export function Users({
|
||||
);
|
||||
})()}
|
||||
|
||||
{quotaDefault !== undefined && (
|
||||
<QuotaDefaultCard
|
||||
quotaDefault={quotaDefault}
|
||||
isAdmin={isAdmin}
|
||||
onSaved={(message) => {
|
||||
toast.success(message);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
{children}
|
||||
@@ -559,6 +578,7 @@ export function Users({
|
||||
{editing && (
|
||||
<UserEditDialog
|
||||
user={editing}
|
||||
quotaDefault={quotaDefault ?? null}
|
||||
self={editing.id === me.id}
|
||||
users={users ?? []}
|
||||
onClose={() => setEditing(null)}
|
||||
|
||||
@@ -362,6 +362,11 @@ describe("Artifacts", () => {
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
// Over the page, outside the card list that clips its overflow — and a
|
||||
// click inside it keeps it open.
|
||||
expect(box.parentElement).toBe(document.body);
|
||||
await user.click(within(box).getByText(/khoa/));
|
||||
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.30 (rebuild client to match)
|
||||
UI build v2.8.43 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+17
-8
@@ -89,9 +89,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
@@ -105,11 +105,12 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
|
||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||
| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode |
|
||||
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
|
||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
|
||||
@@ -816,6 +817,11 @@ that file.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- A row longer than **4,000** characters (an inlined base64 image or video, a
|
||||
minified bundle) comes back cut to its first 1,000 with `elided: <characters
|
||||
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
|
||||
the hunks after it. Each file's raw patch is read up to 16 M characters; past
|
||||
that it is still `truncated`.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
@@ -941,8 +947,11 @@ read the disk instead for a folder that is not a git repo (below).
|
||||
GET /api/lanes/:id/git/branches
|
||||
```
|
||||
|
||||
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the workspace's own repo, for the branch switcher. The repo
|
||||
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the workspace's own repo, for the branch switcher, and
|
||||
`remotes`: the fetched remote-tracking branches that have no local branch of
|
||||
that name yet (the remotes' `HEAD` left out). The switcher lists them under
|
||||
"On the remote" and re-reads the list after every fetch and pull. The repo
|
||||
comes from the workspace row, never from the query (that is what `GET
|
||||
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
|
||||
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
|
||||
@@ -1058,7 +1067,7 @@ cannot collide on `index.lock`.
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
@@ -2584,7 +2593,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
@@ -3032,7 +3041,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
|
||||
DELETE /api/tool-profiles/:tool Body: { name }
|
||||
```
|
||||
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
|
||||
+17
-4
File diff suppressed because one or more lines are too long
+66
-1
@@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
||||
workspace list's owner groups, the Users page and its audit log, the Edit user
|
||||
form — it shows their display name when one is set, else the username (the
|
||||
Users page keeps `@username` beside a display name). Logging in, the CLI and
|
||||
the Linux account always use the username.
|
||||
the Linux account always use the username. No one can pose as a colleague: a
|
||||
display name that reads as another account's display name or username — marks,
|
||||
case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters
|
||||
from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user
|
||||
sets it in Settings → Account or an admin does on the Users page. A name an
|
||||
account already has is kept on an unrelated save; rename an old clash from the
|
||||
Users page.
|
||||
|
||||
Under S3 an admin's folder browser opens on their own home too; ticking
|
||||
**Server folder** opens the host account's home (`/home/<host>`), where a
|
||||
@@ -710,6 +716,65 @@ omp login # e.g. anthropic
|
||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
||||
> provider out of their `models.yml` gets it back at their next omp pane.
|
||||
|
||||
## Spend quotas
|
||||
|
||||
An admin limits what each user may spend, in dollars, on the Users page
|
||||
(`server/lib/user-quota.js`):
|
||||
|
||||
- **Default spend quota** card: one limit every user without their own gets
|
||||
(`PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null}`).
|
||||
- **Edit user → Spend quota**: *Default*, *No limit*, or *Their own limit*
|
||||
(`PATCH /api/users/:id` `{quota: {mode: "default" | "unlimited" | "custom", …}}`).
|
||||
- A period is `day` (from local midnight of the zone the admin's browser was
|
||||
in when they saved it) or `5h` (sliding: the current UTC hour and the four
|
||||
before it — spend booked in hour H counts until H + 5 h).
|
||||
- Both writes need the admin's own password again and are audited, like
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS, priced by `calculateCost`. A session is owned through its workspace;
|
||||
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
|
||||
plain terminal — is owned by the user of the Linux account it ran as
|
||||
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
|
||||
else the session's folder): a member's account bills to that member, the
|
||||
host's to the admin ticked **Owns this server's host account** in Edit user
|
||||
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
|
||||
ticked to the only admin. The same rule feeds the status bar's **Today**.
|
||||
A process that drops the pane's environment also drops
|
||||
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
|
||||
spend still counts, and stops the user's next session. The Users table shows
|
||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||
in the status bar (`GET /api/me/quota`).
|
||||
|
||||
Once a user's spend reaches the limit, until the window opens again:
|
||||
|
||||
- **No new session.** Every start, resume or prompt from the workspace home
|
||||
goes through `spawnRun`, which answers `429 EQUOTA` with the reason.
|
||||
- **Claude Code and Codex prompts are refused** by the hooks. Every pane
|
||||
carries `AGENTHUB_QUOTA_FILE`. The dashboard writes that file every minute:
|
||||
the host's under `<data dir>/user-quota/u<id>.json`, a member's (S3) in
|
||||
their handoff dir through `write-as`. While it says `blocked`,
|
||||
`scripts/hook-handler.js` exits 2 on `UserPromptSubmit` with the reason on
|
||||
stderr, and the CLI drops the prompt and shows why. The hook reads only that
|
||||
local file and never asks the dashboard. It fails open: a missing or
|
||||
unreadable file, or a passed `until`, lets the prompt through. Codex takes
|
||||
this only with AgentHub's Codex hooks installed and trusted.
|
||||
- **A member's omp on the shared login** (above) loses its gateway key: the
|
||||
run's key is left out of `clients.json` until the quota opens. Its pane stays.
|
||||
- **Every other pane of theirs is ended.** This covers Antigravity, Cursor, a
|
||||
Codex without trusted hooks (a member's Codex always), an omp on its own
|
||||
login, and any other catalog tool. Plain shells are left alone.
|
||||
|
||||
Limits of the check:
|
||||
|
||||
- Tokens land after a turn, so a user can finish one turn past the limit
|
||||
(omp/Codex journals are polled, so a little more).
|
||||
- A user's own terminal outside AgentHub carries no flag file.
|
||||
- Without S3 every pane runs as the host account, which can edit the flag.
|
||||
|
||||
Turn the minute sweep off with `DASHBOARD_QUOTA_SWEEP_MS=0`. The `EQUOTA`
|
||||
refusal at start does not depend on it.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
|
||||
+186
-5
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.29
|
||||
version: 2.8.40
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -4657,6 +4657,73 @@ components:
|
||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardEvent'
|
||||
UserQuota:
|
||||
type: object
|
||||
description: 'A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.'
|
||||
required:
|
||||
- limit_usd
|
||||
- period
|
||||
properties:
|
||||
limit_usd:
|
||||
type: number
|
||||
minimum: 0
|
||||
exclusiveMinimum: true
|
||||
period:
|
||||
type: string
|
||||
enum:
|
||||
- day
|
||||
- 5h
|
||||
tz_offset:
|
||||
type: integer
|
||||
minimum: -840
|
||||
maximum: 840
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- user
|
||||
readOnly: true
|
||||
UserQuotaSetting:
|
||||
type: object
|
||||
description: 'A user''s own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.'
|
||||
required:
|
||||
- mode
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum:
|
||||
- default
|
||||
- unlimited
|
||||
- custom
|
||||
limit_usd:
|
||||
type: number
|
||||
period:
|
||||
type: string
|
||||
enum:
|
||||
- day
|
||||
- 5h
|
||||
tz_offset:
|
||||
type: integer
|
||||
UserQuotaStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
- type: object
|
||||
properties:
|
||||
spend:
|
||||
type: number
|
||||
description: USD spent in the current window
|
||||
pct:
|
||||
type: number
|
||||
exceeded:
|
||||
type: boolean
|
||||
description: 'Used up: new panes are refused (EQUOTA) and prompts refused by the hooks'
|
||||
resetsAt:
|
||||
type: string
|
||||
nullable: true
|
||||
description: When it opens again
|
||||
since:
|
||||
type: string
|
||||
description: Start of the current window
|
||||
AccountPin:
|
||||
type: object
|
||||
required:
|
||||
@@ -10975,6 +11042,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: 'EQUOTA (server mode): the workspace owner''s spend quota is used up until it resets.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected failure (EINTERNAL).
|
||||
content:
|
||||
@@ -12203,8 +12276,8 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: The lane repo's own local branches
|
||||
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
|
||||
summary: The lane repo's own local branches, and the remote ones not checked out yet
|
||||
description: Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
|
||||
operationId: listLaneOwnBranches
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -12214,7 +12287,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, branches, current, worktrees}.'
|
||||
description: '{available, branches, remotes, current, worktrees}.'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/commit-message:
|
||||
@@ -14453,6 +14526,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/quota:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getMyQuota
|
||||
summary: The account's own spend quota now (null without one, and in local mode)
|
||||
responses:
|
||||
'200':
|
||||
description: Quota
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuotaStatus'
|
||||
nullable: true
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/instructions:
|
||||
get:
|
||||
tags:
|
||||
@@ -14966,6 +15063,16 @@ paths:
|
||||
type: number
|
||||
tokens:
|
||||
type: integer
|
||||
quotaSetting:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuotaStatus'
|
||||
nullable: true
|
||||
quotaDefault:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
@@ -15303,6 +15410,11 @@ paths:
|
||||
type: boolean
|
||||
enum:
|
||||
- true
|
||||
hostAccount:
|
||||
type: boolean
|
||||
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
@@ -15316,7 +15428,7 @@ paths:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -15413,6 +15525,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/quota-default:
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setDefaultUserQuota
|
||||
summary: 'Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- quota
|
||||
- currentPassword
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
quotaDefault:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
'400':
|
||||
description: EBADQUOTA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
@@ -19757,6 +19935,9 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
description: Files the switch wrote.
|
||||
following:
|
||||
type: boolean
|
||||
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
|
||||
'400':
|
||||
description: '`EBADNAME` / `EBADLABEL`.'
|
||||
content:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.30",
|
||||
"version": "2.8.43",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.30",
|
||||
"version": "2.8.43",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.30",
|
||||
"version": "2.8.43",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -16,9 +16,18 @@
|
||||
* for the response is what made Claude Code sit at "running hooks" for seconds
|
||||
* whenever a dashboard was busy, slow, or wedged.
|
||||
*
|
||||
* The one thing it may refuse: in server mode a pane carries
|
||||
* `AGENTHUB_QUOTA_FILE` (server/lib/user-quota.js), and while that file says
|
||||
* its owner is over their spend quota a Claude Code or Codex
|
||||
* `UserPromptSubmit` exits 2 with the reason on stderr — both CLIs then drop
|
||||
* the prompt and show why. Decided from that local file alone, never by
|
||||
* asking the dashboard, and fail-open: no file, an unreadable one or an
|
||||
* expired `until` lets the prompt through. Antigravity never gets a refusal.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const hookType = process.argv[2] || "unknown";
|
||||
@@ -44,11 +53,36 @@ function resolvePorts() {
|
||||
|
||||
const ports = resolvePorts();
|
||||
|
||||
/** The reason to refuse this prompt (spend quota used up), or null to let it through. */
|
||||
function quotaRefusal() {
|
||||
const file = process.env.AGENTHUB_QUOTA_FILE;
|
||||
if (!file || hookType !== "UserPromptSubmit" || tool === "antigravity") return null;
|
||||
try {
|
||||
if (fs.statSync(file).size > 64 * 1024) return null;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (flag?.blocked !== true) return null;
|
||||
const until = Date.parse(flag.until);
|
||||
if (Number.isFinite(until) && until <= Date.now()) return null;
|
||||
return typeof flag.message === "string" && flag.message
|
||||
? flag.message
|
||||
: "AgentHub: your spend quota is used up.";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refusal = quotaRefusal();
|
||||
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (input += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (refusal) {
|
||||
// The prompt never runs, so it is not reported either.
|
||||
process.stderr.write(`${refusal}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
let parsedData;
|
||||
try {
|
||||
parsedData = JSON.parse(input);
|
||||
|
||||
@@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => {
|
||||
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
|
||||
const { planLabel } = profiles;
|
||||
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
|
||||
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
|
||||
assert.strictEqual(
|
||||
planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null),
|
||||
"Max 5x"
|
||||
);
|
||||
assert.strictEqual(
|
||||
planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null),
|
||||
"Max 20x"
|
||||
);
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
|
||||
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
|
||||
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
|
||||
@@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription
|
||||
assert.strictEqual(planLabel(team, null), "Team");
|
||||
assert.strictEqual(planLabel(null, null), null);
|
||||
});
|
||||
|
||||
it("the profile endpoint's live plan wins over files written at sign-in", () => {
|
||||
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
|
||||
assert.strictEqual(
|
||||
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
|
||||
"Max 5x"
|
||||
);
|
||||
assert.strictEqual(
|
||||
planFromProfile({
|
||||
organization_type: "claude_team",
|
||||
rate_limit_tier: "default_raven",
|
||||
seat_tier: "team_standard",
|
||||
}),
|
||||
"Team Standard"
|
||||
);
|
||||
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
|
||||
assert.strictEqual(planFromProfile(null), null);
|
||||
|
||||
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
|
||||
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
|
||||
const account = { accountUuid: "acct-upgraded" };
|
||||
assert.strictEqual(shownPlan(oauth, account), "Pro");
|
||||
rememberLivePlan("acct-upgraded", "Max 5x");
|
||||
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
|
||||
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
|
||||
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
|
||||
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
|
||||
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
const realFetchUsage = ccUsage.fetchUsage;
|
||||
const realFetchOwner = ccUsage.fetchOwner;
|
||||
|
||||
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
||||
db.prepare(
|
||||
@@ -61,10 +62,12 @@ before(() => {
|
||||
ccUsage.fetchUsage = async () => {
|
||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||
};
|
||||
ccUsage.fetchOwner = async () => null;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
ccUsage.fetchUsage = realFetchUsage;
|
||||
ccUsage.fetchOwner = realFetchOwner;
|
||||
tmux.__setExecImpl(null);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
|
||||
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
|
||||
|
||||
it("cuts the long rows down and still shows the hunks after them", async () => {
|
||||
const body = (mid, end) =>
|
||||
[
|
||||
"<h1>title</h1>",
|
||||
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
||||
mid,
|
||||
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
|
||||
end,
|
||||
"",
|
||||
].join("\n");
|
||||
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "deck");
|
||||
write(
|
||||
LANE,
|
||||
"deck.html",
|
||||
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
|
||||
"<h1>title</h1>",
|
||||
"<h1>new title</h1>"
|
||||
)
|
||||
);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
|
||||
const f = out.files[0];
|
||||
assert.equal(f.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
const lines = f.hunks.flatMap((h) => h.lines);
|
||||
const long = lines.filter((l) => l.elided);
|
||||
assert.equal(long.length, 2);
|
||||
for (const l of long) {
|
||||
assert.ok(l.text.length < 2000);
|
||||
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
|
||||
}
|
||||
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
|
||||
});
|
||||
|
||||
it("leaves ordinary rows whole", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\n");
|
||||
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
|
||||
const lines = out.files[0].hunks.flatMap((h) => h.lines);
|
||||
assert.ok(lines.every((l) => l.elided === undefined));
|
||||
assert.ok(lines.some((l) => l.text === "TWO"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||
|
||||
|
||||
@@ -138,7 +138,10 @@ describe("collectChanges — work range", () => {
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
// `--untracked-files=normal` folded these into one "docs/" row that
|
||||
// opened nothing; the panel needs the files, like JetBrains and VS Code.
|
||||
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
|
||||
assert.deepEqual(out.files.map((f) => f.path).sort(), [
|
||||
"docs/deep/notes.txt",
|
||||
"docs/report.md",
|
||||
]);
|
||||
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
|
||||
assert.equal((await gitFacts(LANE)).untracked, 2);
|
||||
});
|
||||
@@ -200,18 +203,21 @@ describe("collectChanges — work range", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still returns content when a huge file has no newline to cut on", async () => {
|
||||
// A minified bundle is ONE line. Cutting the patch at the last newline
|
||||
// inside the cap would land at index -1 here and hand back an empty
|
||||
// patch — a file changed by a megabyte rendering as "no textual change".
|
||||
it("still returns content when a huge file is one line", async () => {
|
||||
// A minified bundle is ONE line. It used to be cut at the per-file cap
|
||||
// (and, cut at the last newline, could come back empty — a file changed
|
||||
// by a megabyte rendering as "no textual change"); now the row itself is
|
||||
// cut down and says how much it hides, and nothing is truncated.
|
||||
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
||||
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
||||
const entry = out.files[0];
|
||||
assert.equal(entry.truncated, true);
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
|
||||
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
|
||||
assert.equal(entry.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
|
||||
const row = entry.hunks[0].lines.find((l) => l.type === "add");
|
||||
assert.ok(row.text.startsWith("xxx"));
|
||||
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
|
||||
});
|
||||
|
||||
it("reports a binary file without trying to diff it", async () => {
|
||||
@@ -244,10 +250,7 @@ describe("collectChanges — branch range", () => {
|
||||
|
||||
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
|
||||
// No recorded base (an adopted folder): what origin/HEAD names.
|
||||
assert.equal(
|
||||
(await collectChanges(LANE, { range: "branch", base: null })).base,
|
||||
"development"
|
||||
);
|
||||
assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development");
|
||||
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
||||
code: "ENOBASE",
|
||||
});
|
||||
@@ -374,6 +377,48 @@ describe("runGitAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a branch only the remote has after a fetch, and switching tracks it", async () => {
|
||||
const { listRemoteBranches } = require("../lib/worktree");
|
||||
// Someone else pushes a new branch; this clone has never seen it.
|
||||
gc(OTHER, "checkout", "-qb", "feat/remote-only");
|
||||
write(OTHER, "r.txt", "remote\n");
|
||||
gc(OTHER, "add", "-A");
|
||||
gc(OTHER, "commit", "-qm", "remote work");
|
||||
gc(OTHER, "push", "-q", "origin", "feat/remote-only");
|
||||
assert.equal(
|
||||
(await listRemoteBranches(LANE)).some((r) => r.name === "origin/feat/remote-only"),
|
||||
false
|
||||
);
|
||||
await runGitAction(LANE, "fetch");
|
||||
const remote = await listRemoteBranches(LANE);
|
||||
assert.ok(remote.some((r) => r.name === "origin/feat/remote-only"));
|
||||
// origin/HEAD and branches that already have a local one are left out.
|
||||
assert.ok(!remote.some((r) => r.branch === "HEAD" || r.branch === "development"));
|
||||
|
||||
const result = await runGitAction(LANE, "switch", { branch: "origin/feat/remote-only" });
|
||||
assert.equal(result.branch, "feat/remote-only");
|
||||
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/remote-only");
|
||||
assert.equal(
|
||||
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
|
||||
"origin/feat/remote-only"
|
||||
);
|
||||
assert.ok(!(await listRemoteBranches(LANE)).some((r) => r.branch === "feat/remote-only"));
|
||||
gc(LANE, "checkout", "-q", "development");
|
||||
});
|
||||
|
||||
it("switches to a bare remote branch name when exactly one remote has it", async () => {
|
||||
gc(OTHER, "checkout", "-qb", "feat/bare");
|
||||
gc(OTHER, "push", "-q", "origin", "feat/bare");
|
||||
await runGitAction(LANE, "fetch");
|
||||
const result = await runGitAction(LANE, "switch", { branch: "feat/bare" });
|
||||
assert.equal(result.branch, "feat/bare");
|
||||
assert.equal(
|
||||
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
|
||||
"origin/feat/bare"
|
||||
);
|
||||
gc(LANE, "checkout", "-q", "development");
|
||||
});
|
||||
|
||||
it("refuses a branch another worktree holds", async () => {
|
||||
const wt = path.join(ROOT, "sibling");
|
||||
g(LANE, "worktree", "add", "-q", wt, "development");
|
||||
|
||||
@@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
|
||||
assert.deepEqual(r.body.remotes, []); // no remote at all
|
||||
assert.equal(r.body.current, "feat/api-changes");
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
|
||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
|
||||
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
|
||||
}
|
||||
});
|
||||
|
||||
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
|
||||
const home = path.join(root, ".claude");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-upgraded",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
subscriptionType: "pro",
|
||||
rateLimitTier: "default_claude_pro",
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
|
||||
);
|
||||
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url) => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () =>
|
||||
url.endsWith("/profile")
|
||||
? {
|
||||
account: { uuid: "uuid-up", email: "up@x.y" },
|
||||
organization: {
|
||||
organization_type: "claude_max",
|
||||
rate_limit_tier: "default_claude_max_5x",
|
||||
},
|
||||
}
|
||||
: { five_hour: { utilization: 10, resets_at: null } },
|
||||
});
|
||||
try {
|
||||
__resetCache();
|
||||
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
|
||||
assert.strictEqual(live.plan, "Max 5x");
|
||||
// Remembered: a later read from the files alone says the same.
|
||||
const { shownPlan } = require("../lib/cc-profiles");
|
||||
assert.strictEqual(
|
||||
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
|
||||
"Max 5x"
|
||||
);
|
||||
} finally {
|
||||
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prev.home;
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
|
||||
|
||||
// The quota read would go to the network; the pool's list must not.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
|
||||
|
||||
// The quota read would go to the network.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
@@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
assert.equal(bad.body.error.code, "EBADEMAIL");
|
||||
});
|
||||
|
||||
it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
|
||||
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
|
||||
assert.equal(auth.nameKey("Đào"), "dao");
|
||||
// Cyrillic о and а in place of Latin letters.
|
||||
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
// mia's display name is "Mia Tran"; boss has none, only a username.
|
||||
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
|
||||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||
assert.equal(r.status, 409, name);
|
||||
assert.equal(r.body.error.code, "ENAMETAKEN");
|
||||
}
|
||||
assert.equal(auth.findUser("leo").display_name, "Leo N");
|
||||
// Keeping one's own name, or a name of one's own, still works.
|
||||
for (const name of ["Leo N", "Leo Nguyen"]) {
|
||||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||
assert.equal(r.status, 200, name);
|
||||
}
|
||||
});
|
||||
|
||||
it("changes its own password only with the old one, keeping this login", async () => {
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
const other = await login("leo", "member-pass-2");
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file session-account.test.js
|
||||
* @description A session no workspace owns is billed to the user of the Linux
|
||||
* account it ran as (server/lib/session-account.js): a member's account to
|
||||
* that member, the host's to the admin marked as its owner (or the only
|
||||
* admin). A workspace's ownership always wins, a pushed session never gets an
|
||||
* account, and the hook peer is stamped once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "session-account-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const sa = require("../lib/session-account");
|
||||
const scope = require("../lib/scope");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
|
||||
const own = (id) => {
|
||||
const w = scope.ownSessionWhere({ user: { id } });
|
||||
return db
|
||||
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
||||
.all(...w.params)
|
||||
.map((r) => r.id);
|
||||
};
|
||||
|
||||
before(() => {
|
||||
const user = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
user.run(1, "boss", "admin");
|
||||
user.run(2, "dev", "developer");
|
||||
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
||||
);
|
||||
s.run("host-free", "eval", null, "", "local");
|
||||
s.run("dev-free", "eval", null, "dev", "local");
|
||||
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
||||
s.run("unknown", "old", null, null, "local");
|
||||
s.run("gone-member", "x", null, "ghost", "local");
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("session-account", () => {
|
||||
it("bills a member's account to that member and the host's to the only admin", () => {
|
||||
assert.equal(sa.hostOwner(), 1);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
||||
});
|
||||
|
||||
it("lets the workspace's owner win over the account", () => {
|
||||
assert.ok(!own(1).includes("host-in-dev-lane"));
|
||||
});
|
||||
|
||||
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
||||
).run();
|
||||
assert.equal(sa.hostOwner(), null);
|
||||
assert.deepEqual(own(1), []);
|
||||
sa.setHostOwner(3);
|
||||
assert.equal(sa.hostOwner(), 3);
|
||||
assert.deepEqual(own(3), ["host-free"]);
|
||||
sa.setHostOwner(1);
|
||||
assert.deepEqual(own(3), []);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
});
|
||||
|
||||
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
||||
const rows = db.prepare(OWNED_SESSIONS).all();
|
||||
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
||||
assert.equal(by["host-free"], 1);
|
||||
assert.equal(by["dev-free"], 2);
|
||||
assert.equal(by["host-in-dev-lane"], 2);
|
||||
assert.equal(by.unknown, undefined);
|
||||
assert.equal(by["gone-member"], undefined);
|
||||
});
|
||||
|
||||
it("stamps the hook peer once and never a pushed session", () => {
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
||||
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
||||
sa.stampFromPeer("fresh", { kind: "host" });
|
||||
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
||||
sa.stampFromPeer("pushed", { kind: "refused" });
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
assert.equal(acct("fresh"), "dev");
|
||||
assert.equal(acct("pushed"), null);
|
||||
});
|
||||
|
||||
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
||||
).run();
|
||||
sa.stampFromFolders();
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
// Outside S3 every folder is the host's.
|
||||
assert.equal(acct("unknown"), "");
|
||||
assert.equal(acct("pushed"), "");
|
||||
assert.equal(acct("remote"), null);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
||||
|
||||
const log = path.join(ROOT, "codex-argv.log");
|
||||
const bin = path.join(ROOT, "fake-codex.sh");
|
||||
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
||||
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
||||
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||
fs.writeFileSync(sock, "");
|
||||
process.env.AGENTHUB_CODEX_BIN = bin;
|
||||
try {
|
||||
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
||||
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(
|
||||
fs.readFileSync(log, "utf8").trim(),
|
||||
`${process.env.CODEX_HOME} app-server daemon restart`
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_CODEX_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
|
||||
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
|
||||
assert.equal(body.tool_call_count, 2);
|
||||
assert.equal(body.distinct_tool_count, 1);
|
||||
});
|
||||
it("counts each call once in tools_used, not its Pre and Post events", async () => {
|
||||
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
|
||||
assert.ok(one.every((t) => t.count === 1));
|
||||
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
|
||||
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/events labels", () => {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @file user-quota.test.js
|
||||
* @description Spend quotas per user (server/lib/user-quota.js): the default
|
||||
* and a user's own setting, spend over a day and a sliding 5 h window, the
|
||||
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
|
||||
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
|
||||
* leaves out, and the hook handler refusing a prompt from the flag alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const quota = require("../lib/user-quota");
|
||||
const runs = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const ids = {};
|
||||
let laneId;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
|
||||
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
|
||||
function spend(sid, tokens, at = Date.now()) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hourOf(at), tokens);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
|
||||
laneId = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
|
||||
)
|
||||
.run(ids.mia).lastInsertRowid
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("sm", 1_000_000); // $15 now
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
|
||||
assert.deepEqual(quota.effective(ids.mia), {
|
||||
source: "default",
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
tz_offset: -420,
|
||||
});
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
|
||||
assert.equal(quota.effective(ids.mia).source, "user");
|
||||
assert.equal(quota.effective(ids.mia).limit_usd, 50);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
assert.equal(quota.effective(ids.mia).source, "default");
|
||||
quota.setDefault(null);
|
||||
assert.equal(quota.effective(ids.mia), null);
|
||||
});
|
||||
|
||||
it("refuses a bad limit, period or mode", () => {
|
||||
for (const bad of [
|
||||
{ mode: "custom", limit_usd: 0, period: "day" },
|
||||
{ mode: "custom", limit_usd: 5, period: "week" },
|
||||
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
|
||||
{ mode: "nope" },
|
||||
]) {
|
||||
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("spend windows", () => {
|
||||
it("a day starts at the zone's local midnight and resets at the next one", () => {
|
||||
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
|
||||
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
|
||||
assert.equal(w.since, "2026-10-27T17");
|
||||
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
|
||||
const now = Date.parse("2026-10-28T10:20:00Z");
|
||||
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
|
||||
const byHour = new Map([
|
||||
["2026-10-28T06", 4],
|
||||
["2026-10-28T08", 3],
|
||||
["2026-10-28T10", 2],
|
||||
]);
|
||||
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
|
||||
"2026-10-28T11:00:00.000Z"
|
||||
);
|
||||
// limit 3: everything up to 08 must go → 08 + 5 h.
|
||||
assert.equal(
|
||||
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
|
||||
"2026-10-28T13:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the spend of the sessions a user owns, inside the window only", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
|
||||
).run(laneId, new Date().toISOString());
|
||||
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
|
||||
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
|
||||
const s = quota.statusFor(ids.mia);
|
||||
assert.equal(s.spend, 15);
|
||||
assert.equal(s.exceeded, false);
|
||||
assert.equal(s.resetsAt, null);
|
||||
assert.equal(quota.statusFor(ids.lou), null);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
it("an admin sets the default with their password; the list shows each user's quota", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" } },
|
||||
});
|
||||
assert.equal(noPass.status, 403);
|
||||
const bad = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADQUOTA");
|
||||
const ok = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.quotaDefault.limit_usd, 10);
|
||||
|
||||
const list = await call("GET", "/api/users", { cookie: boss });
|
||||
assert.equal(list.body.quotaDefault.limit_usd, 10);
|
||||
const mia = list.body.users.find((u) => u.id === ids.mia);
|
||||
assert.deepEqual(mia.quotaSetting, { mode: "default" });
|
||||
assert.equal(mia.quota.spend, 15);
|
||||
assert.equal(mia.quota.exceeded, true);
|
||||
assert.ok(mia.quota.resetsAt);
|
||||
});
|
||||
|
||||
it("a developer reads their own quota and may not change any", async () => {
|
||||
const me = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/quota", { cookie: me });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.quota.exceeded, true);
|
||||
const put = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: me,
|
||||
body: { quota: null, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(put.status, 403);
|
||||
});
|
||||
|
||||
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(quota.statusFor(ids.mia).exceeded, false);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(ids.mia);
|
||||
assert.match(audit.path, /spend quota \$40\/day/);
|
||||
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcement", () => {
|
||||
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
|
||||
assert.throws(
|
||||
() => quota.assertSpawnAllowed(ids.mia),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EQUOTA");
|
||||
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
|
||||
quota.assertSpawnAllowed(null); // a workspace with no owner
|
||||
});
|
||||
|
||||
it("gives a pane its owner's flag file in server mode only", () => {
|
||||
const env = quota.flagEnv(ids.mia, null);
|
||||
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
|
||||
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
|
||||
assert.equal(quota.statusAll().size, 0);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
|
||||
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
|
||||
const realList = runs.listRuns;
|
||||
const realKill = runs.killRun;
|
||||
const killed = [];
|
||||
runs.listRuns = () => [
|
||||
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
|
||||
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
|
||||
];
|
||||
runs.killRun = (id) => killed.push(id) > 0;
|
||||
try {
|
||||
quota.__reset();
|
||||
const r = quota.sweep();
|
||||
assert.deepEqual(r.blocked, [ids.mia]);
|
||||
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
|
||||
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
|
||||
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(flag.blocked, true);
|
||||
assert.match(flag.message, /quota is used up/);
|
||||
assert.ok(Date.parse(flag.until) > Date.now());
|
||||
// Lou never went over: no file is written for them.
|
||||
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
|
||||
|
||||
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
||||
killed.length = 0;
|
||||
quota.sweep();
|
||||
assert.deepEqual(killed, []);
|
||||
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
|
||||
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
|
||||
} finally {
|
||||
runs.listRuns = realList;
|
||||
runs.killRun = realKill;
|
||||
quota.setUserQuota(ids.mia, { mode: "default" });
|
||||
quota.__reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
|
||||
const { clientsFor } = require("../lib/omp-share");
|
||||
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
|
||||
const out = clientsFor(t, (id) => id === "a");
|
||||
assert.equal(out.a, undefined);
|
||||
assert.deepEqual(out.b, { tokenSha256: "hb" });
|
||||
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook handler", () => {
|
||||
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||
const flag = path.join(TMP, "flag.json");
|
||||
const run = (args, file) =>
|
||||
spawnSync(process.execPath, [HANDLER, ...args], {
|
||||
input: "{}",
|
||||
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
|
||||
fs.writeFileSync(
|
||||
flag,
|
||||
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
|
||||
);
|
||||
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
|
||||
const r = run(args, flag);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /quota used up/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
|
||||
assert.equal(run(["PreToolUse"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
|
||||
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
|
||||
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
fs.writeFileSync(flag, "{not json");
|
||||
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
||||
});
|
||||
});
|
||||
@@ -1445,6 +1445,16 @@ try {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add `account` to sessions — the Linux account a session ran as
|
||||
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
|
||||
// NULL = not known yet. A session no workspace owns is billed to the user of
|
||||
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
|
||||
try {
|
||||
db.prepare("SELECT account FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
|
||||
}
|
||||
|
||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||
@@ -1952,6 +1962,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
// The admin who owns the HOST's Linux account: its sessions outside every
|
||||
// workspace are billed to them (server/lib/session-account.js). At most one.
|
||||
["host_account", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
@@ -2004,6 +2017,23 @@ db.exec(`
|
||||
PRIMARY KEY (group_id, tool, account)
|
||||
);
|
||||
`);
|
||||
// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every
|
||||
// user without a row of their own gets; a row whose limit_usd is NULL means
|
||||
// "no limit". No foreign key (0 is no user) — a deleted user's row goes with
|
||||
// the trigger. Additive.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_quotas (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
limit_usd REAL,
|
||||
period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')),
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users
|
||||
BEGIN
|
||||
DELETE FROM user_quotas WHERE user_id = OLD.id;
|
||||
END;
|
||||
`);
|
||||
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||
// claude/codex. Rebuilt with every pin kept.
|
||||
{
|
||||
@@ -2530,10 +2560,16 @@ const stmts = {
|
||||
FROM events
|
||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||
`),
|
||||
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
|
||||
// or its PostToolUse in a session that has none (imported). Counting every
|
||||
// event with a tool_name counted each call twice.
|
||||
sessionToolUsageCounts: db.prepare(`
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
FROM events
|
||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||
AND event_type = CASE
|
||||
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
|
||||
THEN 'PreToolUse' ELSE 'PostToolUse' END
|
||||
GROUP BY tool_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 15
|
||||
|
||||
@@ -872,6 +872,29 @@ function startBackgroundServices() {
|
||||
}, POOL_REFRESH_MS);
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop — after giving
|
||||
// the sessions no hook made their Linux account (session-account.js), so a
|
||||
// poller's session is billed to someone too. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
Number.isFinite(QUOTA_SWEEP_MS) &&
|
||||
QUOTA_SWEEP_MS > 0 &&
|
||||
require("./lib/auth").isServerMode()
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/session-account").stampFromFolders();
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
}
|
||||
};
|
||||
const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS);
|
||||
if (quotaTimer.unref) quotaTimer.unref();
|
||||
setImmediate(quotaSweep);
|
||||
}
|
||||
// Another dashboard on the same database (hooks reach only one of them)
|
||||
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
||||
// changed — and only while such a dashboard runs at all: alone on its data
|
||||
|
||||
+52
-2
@@ -20,7 +20,8 @@
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||
* An account also carries an optional display name and email
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
|
||||
* ENAMETAKEN), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
@@ -212,6 +213,9 @@ const publicUser = (u) =>
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
// Owns the host's Linux account: its sessions outside every workspace
|
||||
// are billed to this admin (server/lib/session-account.js).
|
||||
hostAccount: Boolean(u.host_account),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -247,6 +251,47 @@ function validateProfile(body) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
|
||||
const CONFUSABLE = {
|
||||
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
|
||||
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
|
||||
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
|
||||
};
|
||||
|
||||
/**
|
||||
* The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case,
|
||||
* look-alike letters folded, and only letters and digits outside brackets —
|
||||
* so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name.
|
||||
*/
|
||||
function nameKey(s) {
|
||||
return String(s || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/\([^)]*\)|\[[^\]]*\]/g, "")
|
||||
.replace(/[đð]/g, "d")
|
||||
.replace(/./gu, (c) => CONFUSABLE[c] ?? c)
|
||||
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse (ENAMETAKEN) a display name that reads as another account's display
|
||||
* name or username, so no one can pose as a colleague. Keeping the name one
|
||||
* already has is always allowed — a clash from before this rule is no reason
|
||||
* to block an admin's unrelated edit.
|
||||
*/
|
||||
function assertNameFree(userId, fields) {
|
||||
if (!("display_name" in fields) || !fields.display_name) return;
|
||||
const key = nameKey(fields.display_name);
|
||||
if (!key) return;
|
||||
const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId);
|
||||
if (own && own.display_name === fields.display_name) return;
|
||||
const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId);
|
||||
if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) {
|
||||
throw err("ENAMETAKEN", "displayName: another account already uses that name");
|
||||
}
|
||||
}
|
||||
|
||||
/** Write `validateProfile`'s result for one account. */
|
||||
function setProfile(userId, fields) {
|
||||
for (const [col, value] of Object.entries(fields)) {
|
||||
@@ -373,7 +418,10 @@ function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
// Only an admin owns the host account (session-account.js): a demotion drops the mark.
|
||||
sql(
|
||||
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
|
||||
).run(role, role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
@@ -812,6 +860,8 @@ module.exports = {
|
||||
unassignWorkspace,
|
||||
ownedWorkspaces,
|
||||
validateProfile,
|
||||
nameKey,
|
||||
assertNameFree,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
|
||||
@@ -220,6 +220,40 @@ function planLabel(oauth, account) {
|
||||
return sub ? cap(sub) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan Anthropic's profile endpoint reports for an account RIGHT NOW,
|
||||
* keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored
|
||||
* `subscriptionType` and rate-limit tiers are written at sign-in, so an
|
||||
* account upgraded since (Pro → Max 5x) keeps reading as its old plan from
|
||||
* the files alone; the live answer wins wherever one was seen.
|
||||
*/
|
||||
const livePlans = new Map();
|
||||
|
||||
/**
|
||||
* `planLabel` of the profile endpoint's `organization` (`organization_type`
|
||||
* `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null.
|
||||
*/
|
||||
function planFromProfile(org) {
|
||||
if (!org || typeof org !== "object") return null;
|
||||
const type = String(org.organization_type || "");
|
||||
return planLabel(
|
||||
{ subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" },
|
||||
{ seatTier: org.seat_tier || "", organizationType: type }
|
||||
);
|
||||
}
|
||||
|
||||
/** Remember an account's live plan (null/unknown leaves the last one). */
|
||||
function rememberLivePlan(accountUuid, plan) {
|
||||
if (!accountUuid || !plan) return;
|
||||
if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine
|
||||
livePlans.set(accountUuid, plan);
|
||||
}
|
||||
|
||||
/** The plan to show: the live one when seen, else what the files say. */
|
||||
function shownPlan(oauth, account) {
|
||||
return livePlans.get(account?.accountUuid) || planLabel(oauth, account);
|
||||
}
|
||||
|
||||
/** Token-free description of an account, safe to send to the browser. */
|
||||
function identityOf({ oauth, account }) {
|
||||
return {
|
||||
@@ -229,8 +263,8 @@ function identityOf({ oauth, account }) {
|
||||
organization: (account && account.organizationName) || null,
|
||||
seatTier: (account && account.seatTier) || null,
|
||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||
/** The readable plan (`planLabel`) — what the UI shows. */
|
||||
plan: planLabel(oauth, account),
|
||||
/** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
|
||||
plan: shownPlan(oauth, account),
|
||||
/** When the stored access token expires; a switch still works after that
|
||||
* (the refresh token is stored too), it just refreshes on first use. */
|
||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||
@@ -588,6 +622,9 @@ module.exports = {
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
planLabel,
|
||||
planFromProfile,
|
||||
rememberLivePlan,
|
||||
shownPlan,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
|
||||
const owners = new Map();
|
||||
|
||||
/**
|
||||
* The account a token belongs to — `{uuid, email}` — or null when the profile
|
||||
* The account a token belongs to — `{uuid, email, plan}` (`plan` as the
|
||||
* profile endpoint reports it now, see cc-profiles.js `livePlans`) — or null when the profile
|
||||
* endpoint does not say. A saved profile's label and its token come from two
|
||||
* different files (`oauthAccount` and the credentials store), and a running
|
||||
* Claude Code session can leave them naming different accounts, so the token's
|
||||
@@ -179,9 +180,14 @@ async function fetchOwner(accessToken) {
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const account = (await res.json())?.account;
|
||||
const body = await res.json();
|
||||
const account = body?.account;
|
||||
if (!account?.uuid) return null;
|
||||
const owner = { uuid: account.uuid, email: account.email || null };
|
||||
// The plan as it is NOW (an upgrade since sign-in included): the stored
|
||||
// files keep the plan the account had when it signed in.
|
||||
const plan = profiles.planFromProfile(body.organization);
|
||||
profiles.rememberLivePlan(account.uuid, plan);
|
||||
const owner = { uuid: account.uuid, email: account.email || null, plan };
|
||||
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
||||
owners.set(accessToken, owner);
|
||||
return owner;
|
||||
|
||||
@@ -379,7 +379,7 @@ const inFlight = new Set();
|
||||
|
||||
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
||||
async function refreshQuotas({ force = false } = {}) {
|
||||
const { fetchUsage } = require("./cc-usage");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
await Promise.all(
|
||||
allAccounts().map(async (a) => {
|
||||
const cached = quotaCache.get(a.name);
|
||||
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
|
||||
}
|
||||
inFlight.add(a.name);
|
||||
try {
|
||||
const u = await fetchUsage(bearerOf(a));
|
||||
// A login's owner too: the profile endpoint's answer is its plan as it
|
||||
// is now (cc-profiles.js livePlans), the stored one is the sign-in's.
|
||||
const [u] = await Promise.all([
|
||||
fetchUsage(bearerOf(a)),
|
||||
a.kind === "login" ? fetchOwner(bearerOf(a)) : null,
|
||||
]);
|
||||
quotaCache.set(a.name, { at: Date.now(), ...u });
|
||||
} catch (e) {
|
||||
// Keep the last good windows: a hiccup must not unblock a full account.
|
||||
@@ -436,7 +441,7 @@ function poolStatus() {
|
||||
kind: a.kind,
|
||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
|
||||
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
|
||||
plan: a.kind === "login" ? require("./cc-profiles").shownPlan(a.oauth, a.account) : null,
|
||||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||
addedAt: a.addedAt,
|
||||
expiresAt: expiresAt(a),
|
||||
|
||||
+55
-5
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
|
||||
const MAX_FILES = 300;
|
||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||
const MAX_FILE_BYTES = 200_000;
|
||||
/** Characters of one file's raw patch read before its long lines are cut down
|
||||
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
|
||||
* or a font as base64 is a few lines of megabytes each: read at
|
||||
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
|
||||
* after it was lost. Past this the patch is still truncated. */
|
||||
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
|
||||
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
|
||||
* characters, with how many it hides (`elided` on the parsed line). */
|
||||
const LONG_LINE_CHARS = 4000;
|
||||
const LONG_LINE_KEEP = 1000;
|
||||
/** Ends a cut-down row in the raw patch: a private-use character, then the
|
||||
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
|
||||
const ELIDED_MARK = "\uE000";
|
||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||
const MAX_TOTAL_BYTES = 1_000_000;
|
||||
/** An untracked file larger than this is reported without content. */
|
||||
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
|
||||
// row of its own and carries no line number.
|
||||
if (raw.startsWith("\\")) continue;
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
let text = raw.slice(1);
|
||||
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
|
||||
let elided = 0;
|
||||
const cut = text.lastIndexOf(ELIDED_MARK);
|
||||
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
|
||||
elided = Number(text.slice(cut + 1));
|
||||
text = text.slice(0, cut);
|
||||
}
|
||||
const extra = elided ? { elided } : {};
|
||||
if (marker === "+") {
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
|
||||
} else if (marker === "-") {
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
|
||||
} else if (marker === " ") {
|
||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||
// string is the tail of the final "\n", not a row.
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
|
||||
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
|
||||
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
|
||||
* re-read git's own diff and match hunks by header, which this never changes.
|
||||
*/
|
||||
function elideLongLines(patch) {
|
||||
if (patch.length <= LONG_LINE_CHARS) return patch;
|
||||
return patch
|
||||
.split("\n")
|
||||
.map((row) =>
|
||||
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
|
||||
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
|
||||
: row
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,7 +689,15 @@ async function collectChanges(
|
||||
untracked: entry.untracked,
|
||||
context: ctx,
|
||||
});
|
||||
// A read that hit its own ceiling came back partial: still cut short,
|
||||
// however small eliding its long rows makes it.
|
||||
const partial = patch.length >= MAX_PATCH_READ_CHARS;
|
||||
patch = elideLongLines(patch);
|
||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||
if (partial) {
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
if (patch.length > cap) {
|
||||
// Cut on a line boundary so the last row is a whole line — but only
|
||||
// when that boundary is near the cap. A minified file is one enormous
|
||||
@@ -1334,6 +1383,7 @@ async function plainTreeAll(dir) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
elideLongLines,
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
|
||||
+26
-1
@@ -55,6 +55,7 @@ const {
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listRemoteBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
operationInProgress,
|
||||
@@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||
return done(action, await git(dir, ["switch", "-c", branch]), branch);
|
||||
}
|
||||
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
|
||||
if (!exists) {
|
||||
// A branch only a remote has (fetched, never checked out here): its local
|
||||
// tracking branch is created and switched to. Named either as the remote
|
||||
// ref (`origin/feature`) or bare (`feature`, when exactly one remote has
|
||||
// it). The remote ref is looked up among the fetched ones, never passed
|
||||
// through from the request.
|
||||
const remote = (await listRemoteBranches(dir)).filter(
|
||||
(r) => r.name === branch || r.branch === branch
|
||||
);
|
||||
const pick = remote.find((r) => r.name === branch) || (remote.length === 1 ? remote[0] : null);
|
||||
if (!pick) {
|
||||
if (remote.length > 1) {
|
||||
throw bad(
|
||||
"EAMBIGUOUS",
|
||||
`branch ${branch} is on several remotes (${remote.map((r) => r.name).join(", ")}): pick one`
|
||||
);
|
||||
}
|
||||
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
|
||||
pick.branch
|
||||
);
|
||||
}
|
||||
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
|
||||
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
/** A user's group fields: id, username and display name (every viewer reads them). */
|
||||
/** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
|
||||
const userExtra = (u) => ({
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName ?? null,
|
||||
avatarAt: u.avatarAt ?? null,
|
||||
});
|
||||
|
||||
async function collect() {
|
||||
|
||||
+17
-3
@@ -108,16 +108,29 @@ function refill(t) {
|
||||
while (t.standby.length < STANDBY) t.standby.push(newToken());
|
||||
}
|
||||
|
||||
/** The gateway's view: every queued key and every key a run holds — hashes only. */
|
||||
function clientsFor(t) {
|
||||
/**
|
||||
* The gateway's view: every queued key and every key a run holds — hashes
|
||||
* only. A run whose workspace owner is over their spend quota
|
||||
* (user-quota.js) is left out until it opens again: its omp can no longer
|
||||
* reach a model, while its pane and key stay.
|
||||
*/
|
||||
function clientsFor(t, isBlocked = quotaBlocked) {
|
||||
const out = {};
|
||||
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
|
||||
for (const [id, u] of Object.entries(t.used)) {
|
||||
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
|
||||
if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quotaBlocked(runId) {
|
||||
try {
|
||||
return require("./user-quota").isRunBlocked(runId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let lastClients = null;
|
||||
|
||||
function save(t) {
|
||||
@@ -519,6 +532,7 @@ module.exports = {
|
||||
takeToken,
|
||||
pruneTokens,
|
||||
clientsFor,
|
||||
clientsFor,
|
||||
readTokens,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
|
||||
@@ -471,6 +471,8 @@ function spawnRun(args) {
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
@@ -684,6 +686,9 @@ function spawnRun(args) {
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
// Server mode: where this pane's hooks look for its owner's quota flag
|
||||
// (user-quota.js) — a refused prompt is decided by the hook, locally.
|
||||
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
|
||||
+12
-7
@@ -36,7 +36,7 @@
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
|
||||
* subscription's name: Claude's plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`),
|
||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||
* `expiresAt`, when its stored access token runs out — Claude's
|
||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
||||
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
|
||||
const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
|
||||
code: "EWRONGACCOUNT",
|
||||
});
|
||||
}
|
||||
return claudeWindows(usage);
|
||||
// The plan as the profile endpoint says it now (null: it did not answer).
|
||||
return { windows: claudeWindows(usage), plan: owner?.plan || null };
|
||||
}
|
||||
|
||||
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
||||
@@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
plan: planLabel(oauth, account),
|
||||
plan: shownPlan(oauth, account),
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
return {
|
||||
...who,
|
||||
account: owner?.email || who.account,
|
||||
// Live: the files keep the plan of the sign-in, not of an upgrade since.
|
||||
plan: owner?.plan || who.plan,
|
||||
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
||||
windows: claudeWindows(usage),
|
||||
};
|
||||
@@ -183,9 +186,9 @@ async function profileClaude(item) {
|
||||
}
|
||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||
try {
|
||||
const windows = await usageFor(creds.accessToken, item.accountUuid);
|
||||
const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid);
|
||||
lastGood.set(item.name, { windows, at: Date.now() });
|
||||
return { ...who, windows };
|
||||
return { ...who, plan: plan || who.plan, windows };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
@@ -289,7 +292,9 @@ function jwtClaims(token) {
|
||||
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
||||
function codexAccount(codexHome = getCodexHome()) {
|
||||
try {
|
||||
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
|
||||
const { tokens } = JSON.parse(
|
||||
readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })
|
||||
);
|
||||
const { exp } = jwtClaims(tokens.access_token);
|
||||
const id = jwtClaims(tokens.id_token);
|
||||
return {
|
||||
|
||||
+12
-6
@@ -5,7 +5,9 @@
|
||||
*
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
@@ -17,6 +19,7 @@
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { ACCOUNT_OWNED } = require("./session-account");
|
||||
|
||||
/** The user whose rows this request is limited to, or null for "all". */
|
||||
function memberId(req) {
|
||||
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
||||
return {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
|
||||
params: [uid, uid],
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
|
||||
|
||||
const STAFF = { staffOnly: true };
|
||||
|
||||
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
|
||||
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||||
function sessionOwners(sessionId) {
|
||||
if (!sessionId) return new Set();
|
||||
const rows = sql(
|
||||
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
||||
UNION
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
|
||||
).all(String(sessionId), String(sessionId));
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||
UNION
|
||||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file session-account.js
|
||||
* @description Which Linux account a session ran as (`sessions.account`: ""
|
||||
* = the host's, else an S3 member's name), and whose bill a session no
|
||||
* workspace owns goes on. A workspace owns a session through `lane_id` or a
|
||||
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
|
||||
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
|
||||
* neither, and without this its spend counted for nobody: not in anyone's
|
||||
* Today, the Users page or a spend quota.
|
||||
*
|
||||
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
|
||||
* loopback connection, never the body) when a hook creates the session; a
|
||||
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
|
||||
* gets it from its folder, the S3 rule `accountForPath` already applies to
|
||||
* every pane. A pushed session (another machine's) never gets one. A member
|
||||
* account bills to the user of that name; the host account to the admin an
|
||||
* admin marked as its owner (`users.host_account`), or — with no mark — the
|
||||
* only enabled admin, if there is exactly one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
|
||||
const HOST_OWNER = `COALESCE(
|
||||
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
|
||||
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||
|
||||
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||
// a run of one — exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||
|
||||
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
|
||||
const ACCOUNT_OWNED = `
|
||||
SELECT s.id AS session_id, u.id AS uid
|
||||
FROM sessions s JOIN users u
|
||||
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
|
||||
ELSE (SELECT id FROM users WHERE username = s.account) END
|
||||
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
|
||||
|
||||
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
|
||||
function accountOfPeer(peer) {
|
||||
if (!peer || peer.kind === "host") return "";
|
||||
if (peer.kind === "member" && peer.name) return peer.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
|
||||
function stampFromPeer(sessionId, peer, pushSource = null) {
|
||||
if (pushSource || !sessionId) return;
|
||||
const account = accountOfPeer(peer);
|
||||
if (account == null) return;
|
||||
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
|
||||
}
|
||||
|
||||
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
|
||||
function accountOfPath(cwd) {
|
||||
const linux = require("./linux-accounts");
|
||||
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||
const root = conf?.usersRoot;
|
||||
const path = require("path");
|
||||
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
|
||||
const rel = path.relative(root, path.resolve(cwd));
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
|
||||
// The users root itself is nobody's.
|
||||
return rel.split(path.sep)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every local session still without an account the one its folder runs
|
||||
* as (sessions the pollers made, and history from before the column). Cheap
|
||||
* once caught up: only `account IS NULL` rows are read.
|
||||
*/
|
||||
function stampFromFolders() {
|
||||
const rows = sql(
|
||||
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
|
||||
).all();
|
||||
if (!rows.length) return 0;
|
||||
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
|
||||
let n = 0;
|
||||
require("../db").db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const account = accountOfPath(r.cwd);
|
||||
if (account != null) n += set.run(account, r.id).changes;
|
||||
}
|
||||
})();
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
|
||||
function setHostOwner(userId) {
|
||||
require("../db").db.transaction(() => {
|
||||
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
|
||||
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** The user id the host account bills to right now, or null. */
|
||||
function hostOwner() {
|
||||
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_OWNED,
|
||||
accountOfPeer,
|
||||
stampFromPeer,
|
||||
accountOfPath,
|
||||
stampFromFolders,
|
||||
setHostOwner,
|
||||
hostOwner,
|
||||
};
|
||||
@@ -6,7 +6,9 @@
|
||||
* swap it back in later, touching nothing but that login.
|
||||
*
|
||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||
* key). Config, sessions, skills and history stay where they are.
|
||||
* key). Config, sessions, skills and history stay where they are. A
|
||||
* switch then restarts Codex's shared app-server daemon when one runs,
|
||||
* so running and new sessions follow the new login (`follow`).
|
||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||
@@ -85,6 +87,15 @@ const codex = {
|
||||
writeJson(codexAuthFile(), secret);
|
||||
return [codexAuthFile()];
|
||||
},
|
||||
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
||||
// outlives every TUI and holds the login it started with: a new auth.json
|
||||
// reached neither a running session nor a NEW one, which kept the old
|
||||
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
||||
// login, and every open TUI reconnects to it on its own (verified on
|
||||
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
||||
follow() {
|
||||
return restartCodexDaemon();
|
||||
},
|
||||
identify(auth) {
|
||||
if (auth.tokens && auth.tokens.id_token) {
|
||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||
@@ -107,6 +118,26 @@ const codex = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
||||
const codexDaemonSocket = () =>
|
||||
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
||||
|
||||
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
||||
function restartCodexDaemon() {
|
||||
if (!fs.existsSync(codexDaemonSocket())) return false;
|
||||
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
||||
if (!bin) return false;
|
||||
cp.execFile(
|
||||
bin,
|
||||
["app-server", "daemon", "restart"],
|
||||
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
||||
(e) => {
|
||||
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
|
||||
}
|
||||
const target = readProfile(tool, name);
|
||||
const written = a.write(target.secret, live);
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
// `following`: running sessions take the new login without a restart of their own.
|
||||
const following = typeof a.follow === "function" ? a.follow() : false;
|
||||
return { ok: true, name, savedOutgoing, written, following };
|
||||
}
|
||||
|
||||
function deleteProfile(tool, name) {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* @file user-quota.js
|
||||
* @description Spend quotas per user (server mode only; local mode has one
|
||||
* user and never reads any of this). An admin sets a DEFAULT quota (row
|
||||
* `user_id = 0` of `user_quotas`) and may override it per user — a limit of
|
||||
* their own, or none at all (`limit_usd` NULL). A quota is `limit_usd` per
|
||||
* `period`: `day` (from the local midnight of `tz_offset`, the admin's
|
||||
* browser's getTimezoneOffset() when they saved it) or `5h` (a sliding
|
||||
* window: the current UTC hour and the four before it — spend booked in hour
|
||||
* H counts until H + 5 h). Spend is the users page's own booking
|
||||
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace,
|
||||
* priced by `calculateCost`), never a second formula.
|
||||
*
|
||||
* A user over their quota cannot chat on until it resets:
|
||||
* - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start,
|
||||
* resume and a prompt from the workspace home all pass `spawnRun`;
|
||||
* - every pane carries `AGENTHUB_QUOTA_FILE` (`flagEnv`), and `sweep` writes
|
||||
* that file `{blocked, until, message}` — the host's under the data dir,
|
||||
* a member's (S3) in their handoff dir through `write-as` — so the hook
|
||||
* handler refuses a `UserPromptSubmit` of Claude Code or Codex locally,
|
||||
* without asking the dashboard (scripts/hook-handler.js; fail-open);
|
||||
* - a member's omp on the shared login loses its gateway key while blocked
|
||||
* (`isRunBlocked`, omp-share.js `clientsFor`);
|
||||
* - a pane no hook can stop (Antigravity, Cursor, a Codex without trusted
|
||||
* hooks, an omp on its own login, any other catalog tool) is ended.
|
||||
* Spend lands after a turn, so a user may end one turn past the limit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const PERIODS = ["day", "5h"];
|
||||
const DEFAULT_ID = 0;
|
||||
const HOUR_MS = 3_600_000;
|
||||
const WINDOW_HOURS = 5;
|
||||
|
||||
const db = () => require("../db").db;
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const isServerMode = () => require("./auth").isServerMode();
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
const hourMs = (h) => Date.parse(`${h}:00:00.000Z`);
|
||||
const round = (n) => Math.round(n * 10000) / 10000;
|
||||
|
||||
// ── settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
function row(userId) {
|
||||
return db()
|
||||
.prepare("SELECT limit_usd, period, tz_offset FROM user_quotas WHERE user_id = ?")
|
||||
.get(userId);
|
||||
}
|
||||
|
||||
const asConfig = (r, source) =>
|
||||
r && r.limit_usd != null
|
||||
? { source, limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset }
|
||||
: null;
|
||||
|
||||
/** The default quota, or null when there is none. */
|
||||
function getDefault() {
|
||||
return asConfig(row(DEFAULT_ID), "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's own setting: `default` (no row — the default applies),
|
||||
* `unlimited`, or `custom` with its limit.
|
||||
*/
|
||||
function getOverride(userId) {
|
||||
const r = row(userId);
|
||||
if (!r) return { mode: "default" };
|
||||
if (r.limit_usd == null) return { mode: "unlimited" };
|
||||
return { mode: "custom", limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset };
|
||||
}
|
||||
|
||||
/** The quota that binds `userId` now, or null (no limit). */
|
||||
function effective(userId) {
|
||||
const r = row(userId);
|
||||
if (r) return asConfig(r, "user");
|
||||
return getDefault();
|
||||
}
|
||||
|
||||
/** `{limit_usd, period, tz_offset}` checked; throws EBADQUOTA. */
|
||||
function validate(cfg) {
|
||||
const limit = Number(cfg?.limit_usd);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 1_000_000) {
|
||||
throw fail("EBADQUOTA", "limit_usd must be a positive number of dollars");
|
||||
}
|
||||
if (!PERIODS.includes(cfg.period)) {
|
||||
throw fail("EBADQUOTA", `period must be one of: ${PERIODS.join(", ")}`);
|
||||
}
|
||||
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
|
||||
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
|
||||
throw fail("EBADQUOTA", "tz_offset must be minutes within ±840");
|
||||
}
|
||||
return { limit_usd: limit, period: cfg.period, tz_offset: tz };
|
||||
}
|
||||
|
||||
function upsert(userId, limit, period, tz) {
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO user_quotas (user_id, limit_usd, period, tz_offset, updated_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET limit_usd = excluded.limit_usd,
|
||||
period = excluded.period, tz_offset = excluded.tz_offset,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(userId, limit, period, tz);
|
||||
}
|
||||
|
||||
/** Set the default quota (`null` removes it: no limit for anyone without their own). */
|
||||
function setDefault(cfg) {
|
||||
if (cfg == null) {
|
||||
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(DEFAULT_ID);
|
||||
return null;
|
||||
}
|
||||
const v = validate(cfg);
|
||||
upsert(DEFAULT_ID, v.limit_usd, v.period, v.tz_offset);
|
||||
return getDefault();
|
||||
}
|
||||
|
||||
/** A user's setting checked: `{mode}` or `{mode: "custom", limit_usd, period, tz_offset}`. */
|
||||
function validateUserQuota(q) {
|
||||
const mode = q?.mode;
|
||||
if (mode === "default" || mode === "unlimited") return { mode };
|
||||
if (mode === "custom") return { mode, ...validate(q) };
|
||||
throw fail("EBADQUOTA", "quota.mode must be one of: default, unlimited, custom");
|
||||
}
|
||||
|
||||
/** A user's setting: `{mode: "default" | "unlimited" | "custom", …}`. */
|
||||
function setUserQuota(userId, q) {
|
||||
if (!Number.isInteger(userId) || userId <= 0) throw fail("EBADQUOTA", "no such user");
|
||||
const v = validateUserQuota(q);
|
||||
if (v.mode === "default") {
|
||||
db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(userId);
|
||||
} else if (v.mode === "unlimited") {
|
||||
upsert(userId, null, "day", 0);
|
||||
} else {
|
||||
upsert(userId, v.limit_usd, v.period, v.tz_offset);
|
||||
}
|
||||
return getOverride(userId);
|
||||
}
|
||||
|
||||
/** `$5/day`, `$2.50/5h`, `no limit` — for the audit log. */
|
||||
function describe(q) {
|
||||
if (!q || q.mode === "unlimited") return "no limit";
|
||||
if (q.mode === "default") return "the default";
|
||||
return `$${q.limit_usd}/${q.period}`;
|
||||
}
|
||||
|
||||
// ── spend ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The window a quota counts over at `now`: `since` (a UTC hour,
|
||||
* `YYYY-MM-DDTHH`, as hourly token rows are keyed) and, for a day, when the
|
||||
* next one starts. Buckets are whole UTC hours, so a half-hour zone's day
|
||||
* starts up to 30 min early.
|
||||
*/
|
||||
function windowOf(cfg, now) {
|
||||
if (cfg.period === "5h") {
|
||||
return { since: hourOf(now - (WINDOW_HOURS - 1) * HOUR_MS), nextReset: null };
|
||||
}
|
||||
const off = cfg.tz_offset || 0;
|
||||
const local = new Date(now - off * 60_000);
|
||||
local.setUTCHours(0, 0, 0, 0);
|
||||
const start = local.getTime() + off * 60_000;
|
||||
return { since: hourOf(start), nextReset: start + 24 * HOUR_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
* When a 5 h quota opens again: the oldest hours leave the window first, so
|
||||
* it is the end of the first hour whose leaving takes the spend under the limit.
|
||||
*/
|
||||
function slidingReset(byHour, spend, limit) {
|
||||
let left = spend;
|
||||
for (const [h, cost] of [...byHour].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
||||
left -= cost;
|
||||
if (left < limit) return hourMs(h) + WINDOW_HOURS * HOUR_MS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every user's quota and spend now: Map(user id → status). Only users with a
|
||||
* quota are listed. One token read for all of them.
|
||||
* @returns {Map<number, {limit_usd, period, tz_offset, source, spend, pct,
|
||||
* exceeded: boolean, resetsAt: string|null, since: string}>}
|
||||
*/
|
||||
function statusAll({ now = Date.now(), userIds = null } = {}) {
|
||||
const out = new Map();
|
||||
if (!isServerMode()) return out;
|
||||
const ids =
|
||||
userIds ||
|
||||
db()
|
||||
.prepare("SELECT id FROM users WHERE disabled = 0")
|
||||
.all()
|
||||
.map((r) => r.id);
|
||||
const quotas = [];
|
||||
for (const id of ids) {
|
||||
const cfg = effective(id);
|
||||
if (cfg) quotas.push({ id, cfg, win: windowOf(cfg, now) });
|
||||
}
|
||||
if (!quotas.length) return out;
|
||||
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
const rules = require("../db").stmts.listPricing.all();
|
||||
const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since);
|
||||
const owner = new Map();
|
||||
for (const r of db().prepare(OWNED_SESSIONS).all()) {
|
||||
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||
}
|
||||
// A session that spent in the window was touched in it.
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
|
||||
[oldest]
|
||||
);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
const uid = owner.get(r.session_id);
|
||||
if (uid == null) continue;
|
||||
if (!byUser.has(uid)) byUser.set(uid, []);
|
||||
byUser.get(uid).push(r);
|
||||
}
|
||||
for (const { id, cfg, win } of quotas) {
|
||||
const mine = (byUser.get(id) || []).filter((r) => r.date >= win.since);
|
||||
const spend = mine.length ? calculateCost(mine, rules).total_cost : 0;
|
||||
const exceeded = spend >= cfg.limit_usd;
|
||||
let resetsAt = win.nextReset;
|
||||
if (cfg.period === "5h") {
|
||||
const hours = new Map();
|
||||
for (const r of mine) {
|
||||
if (!hours.has(r.date)) hours.set(r.date, []);
|
||||
hours.get(r.date).push(r);
|
||||
}
|
||||
const byHour = new Map(
|
||||
[...hours].map(([h, list]) => [h, calculateCost(list, rules).total_cost])
|
||||
);
|
||||
resetsAt = exceeded ? slidingReset(byHour, spend, cfg.limit_usd) : null;
|
||||
}
|
||||
out.set(id, {
|
||||
...cfg,
|
||||
spend: round(spend),
|
||||
pct: round((spend / cfg.limit_usd) * 100),
|
||||
exceeded,
|
||||
resetsAt: resetsAt ? new Date(resetsAt).toISOString() : null,
|
||||
since: `${win.since}:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One user's status, or null without a quota. */
|
||||
function statusFor(userId, opts = {}) {
|
||||
if (userId == null) return null;
|
||||
return statusAll({ ...opts, userIds: [userId] }).get(userId) || null;
|
||||
}
|
||||
|
||||
/** "HH:MM" (and the date when it is not today) in the quota's own zone. */
|
||||
function localTime(iso, tzOffset, now = Date.now()) {
|
||||
if (!iso) return null;
|
||||
const off = (tzOffset || 0) * 60_000;
|
||||
const at = new Date(Date.parse(iso) - off).toISOString();
|
||||
const today = new Date(now - off).toISOString().slice(0, 10);
|
||||
const utc = -(tzOffset || 0) / 60;
|
||||
const zone = `UTC${utc >= 0 ? "+" : ""}${utc}`;
|
||||
return `${at.slice(0, 10) === today ? "" : `${at.slice(0, 10)} `}${at.slice(11, 16)} ${zone}`;
|
||||
}
|
||||
|
||||
/** The text a refused prompt or start shows. */
|
||||
function message(s, now = Date.now()) {
|
||||
const per = s.period === "5h" ? "5 hours" : "day";
|
||||
const when = localTime(s.resetsAt, s.tz_offset, now);
|
||||
return (
|
||||
`AgentHub: your spend quota is used up — $${s.spend.toFixed(2)} of $${s.limit_usd.toFixed(2)} per ${per}.` +
|
||||
(when ? ` It opens again at ${when}.` : "") +
|
||||
" Ask an admin to raise it if you need more."
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */
|
||||
function assertSpawnAllowed(ownerId) {
|
||||
if (!isServerMode() || ownerId == null) return;
|
||||
const s = statusFor(ownerId);
|
||||
if (s?.exceeded) throw Object.assign(fail("EQUOTA", message(s)), { quota: s });
|
||||
}
|
||||
|
||||
// ── the flag file the hook handler reads ─────────────────────────────────
|
||||
|
||||
const FLAG = "quota.json";
|
||||
const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota");
|
||||
const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`);
|
||||
|
||||
/**
|
||||
* The env a pane of `ownerId` gets: where its hooks look for the flag —
|
||||
* the member's handoff dir (S3: the pane runs as them) or the host's data dir.
|
||||
*/
|
||||
function flagEnv(ownerId, member) {
|
||||
if (!isServerMode() || ownerId == null) return {};
|
||||
const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId);
|
||||
return { AGENTHUB_QUOTA_FILE: file };
|
||||
}
|
||||
|
||||
function writeHostFlag(userId, text) {
|
||||
fs.mkdirSync(hostDir(), { recursive: true, mode: 0o700 });
|
||||
const file = hostFlag(userId);
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/** Write a user's flag everywhere their panes may read it. Best effort per place. */
|
||||
function writeFlags(user, s, now) {
|
||||
const body = s?.exceeded
|
||||
? { blocked: true, until: s.resetsAt, message: message(s, now) }
|
||||
: { blocked: false };
|
||||
const text = `${JSON.stringify(body)}\n`;
|
||||
try {
|
||||
writeHostFlag(user.id, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: flag for ${user.username} not written:`, err.message);
|
||||
}
|
||||
let acct = null;
|
||||
try {
|
||||
acct = require("./run-as").accountForUser(user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (!acct) return;
|
||||
try {
|
||||
require("./run-as").handoffWrite(acct, FLAG, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── the sweep ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Users over their quota now (by the last sweep). */
|
||||
let blocked = new Set();
|
||||
/** user id → the flag text last written, so an unchanged state writes nothing. */
|
||||
const written = new Map();
|
||||
|
||||
function laneOwner(laneId) {
|
||||
return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null;
|
||||
}
|
||||
|
||||
/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */
|
||||
function isRunBlocked(runId) {
|
||||
if (!blocked.size) return false;
|
||||
const laneId = require("./pty-run").laneIdFromRunId(runId);
|
||||
return laneId != null && blocked.has(laneOwner(laneId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a hook refuse this pane's next prompt? Claude Code always (the plugin's
|
||||
* hooks, a member's managed-settings hooks); Codex only on the host's own
|
||||
* CODEX_HOME with our UserPromptSubmit hook installed and trusted (members
|
||||
* get no Codex hooks); a member's omp on the shared login through its
|
||||
* gateway key. Everything else must be ended.
|
||||
*/
|
||||
function hookCanStop(run, member) {
|
||||
if (run.tool === "claude" || run.tool === "shell") return true;
|
||||
if (run.tool === "codex" && !member) {
|
||||
try {
|
||||
const ev = require("./codex-hooks").status().events?.UserPromptSubmit;
|
||||
return !!(ev?.installed && ev?.trusted);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (run.tool === "omp" && member) {
|
||||
try {
|
||||
const share = require("./omp-share");
|
||||
return share.available() && share.enabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** End the live panes of `userIds` that no hook can stop. Returns the run ids ended. */
|
||||
function endUnstoppable(userIds) {
|
||||
if (!userIds.size) return [];
|
||||
const runs = require("./pty-run");
|
||||
const runAs = require("./run-as");
|
||||
const ended = [];
|
||||
for (const run of runs.listRuns()) {
|
||||
if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue;
|
||||
if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue;
|
||||
try {
|
||||
if (runs.killRun(run.id)) ended.push(run.id);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${run.id} not ended:`, err.message);
|
||||
}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute every user's state, rewrite the flags that changed, end the
|
||||
* panes no hook can stop and re-list the omp gateway's keys. Called every
|
||||
* minute by the server and right after an admin changes a quota.
|
||||
*/
|
||||
function sweep({ now = Date.now() } = {}) {
|
||||
if (!isServerMode()) return { blocked: [] };
|
||||
const users = db().prepare("SELECT id, username FROM users").all();
|
||||
const status = statusAll({ now });
|
||||
const next = new Set();
|
||||
for (const u of users) {
|
||||
const s = status.get(u.id) || null;
|
||||
if (s?.exceeded) next.add(u.id);
|
||||
const text = s?.exceeded ? `${s.resetsAt}|${s.limit_usd}` : "open";
|
||||
// A user never blocked has no flag to clear: nothing is written for them.
|
||||
if (written.get(u.id) === text || (!written.has(u.id) && !s?.exceeded && !hadFlag(u.id))) {
|
||||
continue;
|
||||
}
|
||||
writeFlags(u, s, now);
|
||||
written.set(u.id, text);
|
||||
}
|
||||
const changed = next.size !== blocked.size || [...next].some((id) => !blocked.has(id));
|
||||
blocked = next;
|
||||
const ended = endUnstoppable(next);
|
||||
if (changed) {
|
||||
try {
|
||||
require("./omp-share").syncClients();
|
||||
} catch {
|
||||
/* sharing off or not available */
|
||||
}
|
||||
}
|
||||
return { blocked: [...next], ended };
|
||||
}
|
||||
|
||||
/** A flag left by an earlier server (it may still say blocked). */
|
||||
function hadFlag(userId) {
|
||||
try {
|
||||
return fs.existsSync(hostFlag(userId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget what the sweep remembered. */
|
||||
function __reset() {
|
||||
blocked = new Set();
|
||||
written.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERIODS,
|
||||
getDefault,
|
||||
getOverride,
|
||||
effective,
|
||||
setDefault,
|
||||
setUserQuota,
|
||||
validateUserQuota,
|
||||
describe,
|
||||
windowOf,
|
||||
slidingReset,
|
||||
statusAll,
|
||||
statusFor,
|
||||
message,
|
||||
assertSpawnAllowed,
|
||||
flagEnv,
|
||||
isRunBlocked,
|
||||
hookCanStop,
|
||||
sweep,
|
||||
__reset,
|
||||
};
|
||||
@@ -228,6 +228,39 @@ async function listBranches(sourceRepo) {
|
||||
return { branches, current };
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo's remote-tracking branches that have NO local branch of the same
|
||||
* name yet — what a fetch brought in that `listBranches` cannot show.
|
||||
* `[{name: "origin/feature", remote: "origin", branch: "feature"}]`, the
|
||||
* remotes' symbolic `HEAD`s left out. The remote's name is matched against
|
||||
* `git remote` (longest first), so a remote whose name holds a `/` splits right.
|
||||
*/
|
||||
async function listRemoteBranches(dir) {
|
||||
const remotes = (await git(dir, ["remote"])).stdout
|
||||
.split("\n")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
if (!remotes.length) return [];
|
||||
const refs = (
|
||||
await git(dir, ["for-each-ref", "--format=%(refname:short)%00%(symref)", "refs/remotes"])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
const local = new Set((await listBranches(dir)).branches);
|
||||
const out = [];
|
||||
for (const [name, symref] of refs) {
|
||||
if (symref) continue; // origin/HEAD → origin/main
|
||||
const remote = remotes.find((r) => name.startsWith(`${r}/`));
|
||||
if (!remote) continue;
|
||||
const branch = name.slice(remote.length + 1);
|
||||
if (!branch || branch === "HEAD" || local.has(branch)) continue;
|
||||
out.push({ name, remote, branch });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
|
||||
*/
|
||||
@@ -978,6 +1011,7 @@ module.exports = {
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listRemoteBranches,
|
||||
resolveBase,
|
||||
slugify,
|
||||
listWorktrees,
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
|
||||
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||
@@ -37,6 +38,48 @@ const tags = [
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
UserQuota: {
|
||||
type: "object",
|
||||
description:
|
||||
"A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.",
|
||||
required: ["limit_usd", "period"],
|
||||
properties: {
|
||||
limit_usd: { type: "number", minimum: 0, exclusiveMinimum: true },
|
||||
period: { type: "string", enum: ["day", "5h"] },
|
||||
tz_offset: { type: "integer", minimum: -840, maximum: 840 },
|
||||
source: { type: "string", enum: ["default", "user"], readOnly: true },
|
||||
},
|
||||
},
|
||||
UserQuotaSetting: {
|
||||
type: "object",
|
||||
description:
|
||||
"A user's own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.",
|
||||
required: ["mode"],
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["default", "unlimited", "custom"] },
|
||||
limit_usd: { type: "number" },
|
||||
period: { type: "string", enum: ["day", "5h"] },
|
||||
tz_offset: { type: "integer" },
|
||||
},
|
||||
},
|
||||
UserQuotaStatus: {
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/UserQuota" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
spend: { type: "number", description: "USD spent in the current window" },
|
||||
pct: { type: "number" },
|
||||
exceeded: {
|
||||
type: "boolean",
|
||||
description: "Used up: new panes are refused (EQUOTA) and prompts refused by the hooks",
|
||||
},
|
||||
resetsAt: { type: "string", nullable: true, description: "When it opens again" },
|
||||
since: { type: "string", description: "Start of the current window" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
AccountPin: {
|
||||
type: "object",
|
||||
required: ["userId", "username", "account"],
|
||||
@@ -214,6 +257,32 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/quota": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getMyQuota",
|
||||
summary: "The account's own spend quota now (null without one, and in local mode)",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Quota",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/instructions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -504,11 +573,20 @@ const paths = {
|
||||
type: "object",
|
||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||
},
|
||||
quotaSetting: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
quotaDefault: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -712,10 +790,16 @@ const paths = {
|
||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
signOut: { type: "boolean", enum: [true] },
|
||||
hostAccount: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
|
||||
},
|
||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
}),
|
||||
responses: {
|
||||
...changeResponses,
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"),
|
||||
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
||||
},
|
||||
},
|
||||
@@ -734,6 +818,44 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/quota-default": {
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
operationId: "setDefaultUserQuota",
|
||||
summary:
|
||||
"Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks",
|
||||
requestBody: changeBody(
|
||||
{
|
||||
quota: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
["quota"]
|
||||
),
|
||||
responses: {
|
||||
...changeResponses,
|
||||
200: {
|
||||
description: "Saved",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: { $ref: "#/components/schemas/AuthUser" },
|
||||
quotaDefault: {
|
||||
allOf: [{ $ref: "#/components/schemas/UserQuota" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADQUOTA"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/password": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -854,13 +854,13 @@ const paths = {
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane repo's own local branches",
|
||||
summary: "The lane repo's own local branches, and the remote ones not checked out yet",
|
||||
description:
|
||||
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
|
||||
"Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneOwnBranches",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "{available, branches, current, worktrees}." },
|
||||
200: { description: "{available, branches, remotes, current, worktrees}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -411,6 +411,9 @@ const paths = {
|
||||
409: errorJson(
|
||||
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
|
||||
),
|
||||
429: errorJson(
|
||||
"EQUOTA (server mode): the workspace owner's spend quota is used up until it resets."
|
||||
),
|
||||
503: errorJson("tmux is not installed (ETMUXMISSING)."),
|
||||
500: errorJson("Unexpected failure (EINTERNAL)."),
|
||||
},
|
||||
|
||||
@@ -489,6 +489,11 @@ const paths = {
|
||||
items: { type: "string" },
|
||||
description: "Files the switch wrote.",
|
||||
},
|
||||
following: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Switched."
|
||||
|
||||
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
} else {
|
||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+12
-4
@@ -48,6 +48,7 @@ const {
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listRemoteBranches,
|
||||
listWorktrees,
|
||||
resetWorktree,
|
||||
resolveBase,
|
||||
@@ -1913,7 +1914,9 @@ router.get("/:id/git/tree", async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The lane's own local branches, for the branch switcher. Separate from
|
||||
* The lane's own local branches, for the branch switcher, plus `remotes`: the
|
||||
* fetched remote-tracking branches with no local branch yet (`origin/feature`
|
||||
* — switching to one creates its tracking branch). Separate from
|
||||
* `GET /branches` (which takes a candidate repo PATH from the query, for a
|
||||
* lane that does not exist yet); this one resolves the repo from the lane row,
|
||||
* so no path ever comes from the request.
|
||||
@@ -1925,9 +1928,10 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
try {
|
||||
const { branches, current } = await listBranches(cwd);
|
||||
const worktrees = await listWorktrees(cwd).catch(() => []);
|
||||
res.json({ available: true, branches, current, worktrees });
|
||||
const remotes = (await listRemoteBranches(cwd).catch(() => [])).map((r) => r.name);
|
||||
res.json({ available: true, branches, remotes, current, worktrees });
|
||||
} catch {
|
||||
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
||||
res.json({ available: false, branches: [], remotes: [], current: null, worktrees: [] });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1987,6 +1991,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"EBADMESSAGE",
|
||||
"EBADBRANCH",
|
||||
"ENOBRANCH",
|
||||
"EAMBIGUOUS",
|
||||
"EBRANCHEXISTS",
|
||||
"EBRANCHBUSY",
|
||||
"EDIRTY",
|
||||
@@ -3041,11 +3046,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
|
||||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||
? 400
|
||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
: err.code === "EQUOTA"
|
||||
? 429
|
||||
: 500;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
||||
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
||||
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
||||
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
|
||||
* `null` without one, and in local mode), for the status bar.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
@@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => {
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
|
||||
// limit, period, spend, whether it is used up and when it opens again — or
|
||||
// `{quota: null}` with no limit, and always in local mode.
|
||||
router.get("/quota", (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
|
||||
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
|
||||
});
|
||||
|
||||
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
@@ -185,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit");
|
||||
const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
ENAMETAKEN: 409,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADIMAGE: 400,
|
||||
@@ -250,6 +263,7 @@ router.patch(
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
||||
auth.assertNameFree(user.id, fields);
|
||||
auth.setProfile(user.id, fields);
|
||||
return { out: accountView(user.id), action: "own profile" };
|
||||
})
|
||||
|
||||
@@ -672,6 +672,10 @@ router.post("/", (req, res) => {
|
||||
) {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
||||
if (err.code === "EQUOTA") {
|
||||
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||
// installed. 400 would tell the user to fix their input.
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user