Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway
The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open Settings; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open Settings brings the Models and credentials tab to the top of the page body and focuses the default Harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time.

### Sandbox setup
Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible.
Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible.

### Sandbox lifecycle

Expand Down
2 changes: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The console runs beside the administrator's own Core, with execution, files and
- **Default provider observations.** Each configured Harness offers Usage details for Core's last successful use and any newer classified provider error. Missing records remain unknown; an error at or before the last success is no longer actionable. These are best-effort observations, not readiness checks. Failed refreshes qualify retained records, and replacing the provider starts a new observation history.
- **Host connection.** Core's connection observation and credential metadata share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks Host connected. Stale or failed reads withhold completion. Recovery rotates the bound key, stops the installed daemon, replaces the host credential file and starts the daemon again.
- **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it.
- **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; uncertain write outcomes stay form-level and are never retried automatically.
- **Typed errors.** Every failure the console shows, read or write, follows one rule. Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; a failure without a code names its HTTP status, and one that never reached Core says so. Uncertain write outcomes stay form-level and are never retried automatically.
- **Read failures.** A read is tried again once after a 5xx answer or a failed connection, never after a 4xx answer. Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; a first read that fails never mentions earlier data; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero.
- **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry.
- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/resources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request));

test("keeps Core's reason when it refuses a deletion, then deletes on confirmation", async ({ page, request }) => {
await openConsole(page, request, "agents");
await failNext(request, { method: "DELETE", path: "/agents/", status: 409, message: "The Agent has Sessions in progress." });
await failNext(request, { method: "DELETE", path: "/agents/", status: 409, code: "conflict_error", message: "The Agent has Sessions in progress." });
await page.getByRole("button", { name: "Delete Code reviewer?" }).click();
const dialog = page.getByRole("dialog", { name: "Delete Agent?" });
await dialog.getByRole("button", { name: "Delete Agent" }).click();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/features/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog";
import { ApiEmptyState, EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui";
import { CopyableId, ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui";
import { useConsoleNavigation } from "../../lib/console-navigation";
import { coreError } from "../../lib/core-error";
import { useDeleteFlow } from "../../lib/delete-flow";
import { formatCompact, formatDateTime, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format";
import { harnessNames, protocolNames } from "../../lib/harness-labels";
Expand Down Expand Up @@ -193,7 +194,7 @@ function AgentDetail({ projectId, agentId }: { projectId: string; agentId: strin
);

const agent = read.data ?? null;
const failure = read.isError ? (read.error instanceof Error ? read.error.message : String(read.error)) : null;
const failure = read.isError ? coreError(read.error, tCommon) : null;
// Detail reads are not polled: each failed read was asked for and is reported.
useFailureToast(agent !== null ? failure : null, t("refreshFailed"), "agent-refresh", read.errorUpdatedAt);
const name = agent ? agent.name || t("catalog.untitled") : agentId;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/api-keys/KeyFlowDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function FlowErrorMessage({ error, name }: { error: FlowError | null; nam
const { t } = useTranslation("keys");
const { t: tCommon } = useTranslation("common");
if (!error || (name !== undefined && error.kind === "rejected" && coreFieldError(error.cause, "name", tCommon))) return null;
const text = error.kind === "uncertain" ? t("errors.uncertain") : t("errors.rejected", { message: error.cause ? coreError(error.cause, tCommon) : error.message });
const text = error.kind === "uncertain" ? t("errors.uncertain") : t("errors.rejected", { message: coreError(error.cause, tCommon) });
return <p className="key-flow-error" role="alert">{text}</p>;
}

Expand Down
12 changes: 5 additions & 7 deletions apps/web/src/features/api-keys/key-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,13 @@ describe("project and key names", () => {
});

describe("write outcomes", () => {
it("separates rejected and uncertain results, keeping Core's reason on a conflict", () => {
expect(flowError(new AgentCoreError("Name is invalid.", 400))).toEqual({ kind: "rejected", status: 400, message: "Name is invalid." });
expect(flowError(new AgentCoreError("The target Project is archived.", 409))).toEqual({ kind: "rejected", status: 409, message: "The target Project is archived." });
it("separates rejected and uncertain results, keeping Core's error on a conflict", () => {
const archived = new AgentCoreError("The target Project is archived.", 409, "project_archived");
expect(flowError(archived)).toEqual({ kind: "rejected", cause: archived });
// A timeout, a server error or a lost connection may have written anyway.
expect(flowError(new AgentCoreError("Timeout.", 408))).toEqual({ kind: "uncertain" });
expect(flowError(new AgentCoreError("Down.", 502))).toEqual({ kind: "uncertain" });
expect(flowError(new TypeError("Failed to fetch"))).toEqual({ kind: "uncertain" });
// The client's own name validation never reached Core.
expect(flowError(new TypeError("A key name has 1–80 characters without leading or trailing spaces."))).toMatchObject({ kind: "rejected" });
});
});

Expand Down Expand Up @@ -96,8 +94,8 @@ describe("issuing a key", () => {
const busy = run([{ type: "openIssue", project }, { type: "setName", name: "bob" }, { type: "started" }]);
expect(keyFlowReducer(busy, { type: "cancel" })).toBe(busy);
expect(keyFlowReducer(busy, { type: "setName", name: "changed" })).toBe(busy);
const failed = keyFlowReducer(busy, { type: "failed", error: { kind: "rejected", status: 409, message: "The target Project is archived." } });
expect(failed).toMatchObject({ step: "issue", busy: false, error: { kind: "rejected", status: 409 }, name: "bob" });
const failed = keyFlowReducer(busy, { type: "failed", error: flowError(new AgentCoreError("The target Project is archived.", 409, "project_archived")) });
expect(failed).toMatchObject({ step: "issue", busy: false, error: { kind: "rejected" }, name: "bob" });
expect(keyFlowReducer(failed, { type: "setName", name: "bob-2" })).toMatchObject({ name: "bob-2", error: null });
expect(keyFlowReducer(failed, { type: "cancel" })).toEqual(idleFlow);
});
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/features/api-keys/key-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,11 @@ export function isArchiveConfirmed(name: string, activeKeys: number, typed: stri
* ("uncertain": check the list first; never retried automatically).
*/
export type FlowError =
| { kind: "rejected"; status: number; message: string; cause?: AgentCoreError }
| { kind: "rejected"; cause: AgentCoreError }
| { kind: "uncertain" };

export function flowError(error: unknown): FlowError {
if (error instanceof AgentCoreError) {
if (error.status >= 400 && error.status < 500 && error.status !== 408) return { kind: "rejected", status: error.status, message: error.message, ...(error.code || error.param ? { cause: error } : {}) };
return { kind: "uncertain" };
}
// The client validates names before sending; nothing reached Core.
if (error instanceof TypeError && /name has/i.test(error.message)) return { kind: "rejected", status: 400, message: error.message };
if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) return { kind: "rejected", cause: error };
return { kind: "uncertain" };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog";
import { ApiEmptyState, EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, StatusDot } from "../../components/console-ui";
import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui";
import { useConsoleNavigation } from "../../lib/console-navigation";
import { coreError } from "../../lib/core-error";
import { useDeleteFlow } from "../../lib/delete-flow";
import { formatDateTime, MISSING } from "../../lib/format";
import { CreatorCell, CreatorHeading, forgetCreators, ProjectFilter, ProjectName, projectClient, readAllPages, useCreators, useProjectCollection, useProjects, type Owned } from "../../lib/projects";
Expand Down Expand Up @@ -183,7 +184,7 @@ function TemplateDetailRoute({ projectId, templateId }: { projectId: string; tem
const back = useCallback(() => goBack("templates", { project: projectId }), [goBack, projectId]);
const creators = useCreators("environment_template", useMemo(() => [{ projectId, id: templateId }], [projectId, templateId]));
const refresh = () => { forgetCreators(); void read.refetch(); };
const failure = read.isError ? (read.error instanceof Error ? read.error.message : String(read.error)) : null;
const failure = read.isError ? coreError(read.error, tCommon) : null;
// Detail reads are not polled: each failed read was asked for and is reported.
useFailureToast(read.data ? failure : null, t("refreshFailed"), "template-refresh", read.errorUpdatedAt);

Expand Down
Loading
Loading