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
55 changes: 43 additions & 12 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,12 @@ Every resource list, the Session log and the project list share one grammar:
gaps, two columns below 900px). It starts with the ID (with its copy button) and
the Project and includes the Creator.
- Sections follow: usage figures in a KPI strip, then tables in cards.
- A Session's **History** header holds an outline "Jump to the failed Turn" (with
the count when several failed) before the view switch while any Turn failed;
it shows the conversation (the Turn table when there are no Items), scrolls the
page body to the next failed Turn and focuses it.
- An active project's page ends its keys with a **How to call** section (see
Dialogs) before its write operations.
- A self-hosted Session's **Executor credentials** section ends with a **Connect
a host** card when the console serves the self-hosted installer: a 13px/600
title with a help tip (what revoke and rotate do to the host, how to reconnect,
Expand All @@ -509,7 +515,13 @@ request runs.
confirm button (danger), whose label changes while busy. Core's reason for a
rejection, or an uncertain-outcome warning, appears in red inside the dialog.
The Skill page's delete dialogs follow the same grammar; deleting a whole Skill
also requires typing its name.
also requires typing its name. Archiving a project says in bold that it can't be
undone, then how many active keys it revokes (the project read's count, or more
when its loaded key list shows more) and that assets and accepted work stay;
with active keys it too requires typing the project's name, shown in mono with
its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC).
While the project list is read again Archive waits; if that read failed, a red
line says the count may be out of date and Archive stays disabled.
- **Key dialogs**: name fields carry their rules in a help tip and their problem in
red underneath. The issued key appears in a read-only field with a copy button,
under a notice that it is shown once; only "I've saved this key" dismisses it.
Expand Down Expand Up @@ -553,11 +565,17 @@ request runs.
- **How to call**: wherever a new key is shown, a card under it gives three
copyable samples, each a Margin Gray block with a Hairline and its label and copy
button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the
installation's API base URL) and `OPENAI_API_KEY` (the new key) together, a curl
request and a Python example with the pinned SDK. A copy the clipboard refuses
selects the sample and says so in red underneath. One Graphite line says running
an Agent needs a model provider: in each request, saved on the Agent, or the
deployment default. When the public address is loopback, a note above the
installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then
curl and Python (with the pinned SDK), each listing the project's Agents and
creating a Session with a first message (`environment`, an inline `agent` with
`model: "<model>"`, and `input`). A copy the clipboard refuses
selects the sample and says so in red underneath. One Graphite line says to put
a model the model provider serves in place of `<model>`, and that running an
Agent needs a model provider: in each request, saved on the Agent, or the
deployment default. An active project's page shows the same samples as a
section without any key: the Shell block exports a quoted placeholder, and a
Graphite line above the samples says to use a key issued for this project,
shown only once at issuance. When the public address is loopback, a note above the
samples says the API is reachable only on the Core machine; without a public
address only a note to set one shows. Before the installation is read, a
skeleton holds the first sample's place.
Expand Down Expand Up @@ -591,9 +609,11 @@ hidden element for assistive technology.
### Status dot
A 7px circle plus a plain label at 12.5px: ok green, warning amber, danger red,
pending Series 1 with a soft expanding ring while work is in progress, neutral
Idle Gray. A failed Session's error and a waiting
Session's required actions sit in a help tip beside the label. Never a coloured
pill, never colour alone.
Idle Gray. A waiting Session's required actions sit in a help tip beside the
label. A failed Session's reason, as Core sent it, stays visible under the label
in 12px Graphite: in full on the Session page, its line breaks kept; in the
Session log on one truncated line, with the full text in its tooltip, that never
widens the status column. Never a coloured pill, never colour alone.

### Meter
A 6px pill rail in Meter Track with a neutral ink fill. The fill turns amber at 90%
Expand All @@ -614,7 +634,14 @@ first point; refreshes of the same range redraw in place.
### Tables
A card with a sticky 34px Margin Gray header in Graphite 12px/500, 44px rows divided
by Faint Rules, hover wash, right-aligned tabular numerics, clickable rows where a
detail page exists, and the list grammar above.
detail page exists, and the list grammar above. Agent metrics' By Agent table
links a saved Agent's name to its page and a nonzero Failed figure (in its red) to
the Session log with its project and Agent filters set to that Agent, every
status; both turn indigo on hover. The figure counts failed Turns in the range, as
the column's help tip says, so the link's name and tooltip give that count and say
it opens the Agent's Sessions. A
key count in a section heading reads "3 active · 1 revoked" (revoked left out
at zero).

### Notices
Errors are popups, never lines inserted into a page. A failed action whose outcome
Expand Down Expand Up @@ -660,8 +687,12 @@ numbered ring (a check on the tile wash when done), a 13px/600 title over one
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 System;
Create project (which continues to the new project's first key) or Issue key;
Projects and keys. Add node, Create project and Issue key open their page with the
dialog already open; Open System focuses the default harness's Set or Replace. Every step done turns it into one line, "You're set", with Take the tour and
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 System brings the Default
model section 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
Expand Down
21 changes: 15 additions & 6 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ workbench.
duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d),
Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a
sandbox opens in a dialog with its figures and CPU and memory charts), Session log
(every Session, read-only, opening one Session's history; a self-hosted
Session's page also has its environment's executor credentials).
(every Session, read-only, with a failed Session's reason under its status,
opening one Session's history, which jumps to its failed Turns; a self-hosted
Session's page also has its environment's executor credentials). Agent
metrics' By Agent table opens an Agent's page and, from its failed Turns, its
Sessions in the Session log.
- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list
shows one project or all projects, with a Project column when all are shown and a
Creator column naming the creating key. Detail pages show the resource's facts
Expand Down Expand Up @@ -110,7 +113,9 @@ workbench.
ready (a saved deployment and a node online and ready, or a saved E2B deployment
whose template build is not reported as not ready), a default model on the default
harness (on any enabled harness when none is default),
a project with an active key, and a first Session. Completion comes from reads the
a project with an active key, and a first Session, whose action opens the call
samples of the newest active project, preferring one with an active key.
Completion comes from reads the
console already makes. It can be hidden; Show Getting started in the sidebar
opens it again, and it ends with a brief "You're set". The optional
three-chapter tour of the console (Monitor, Resources, Platform) opens from it,
Expand All @@ -125,9 +130,13 @@ workbench.
- **Projects and keys.** A project owns an isolated set of assets shared by all of
its named API keys; projects do not see each other's assets. Issuing or revoking
a key never touches assets. Archiving a project revokes every key and keeps its
assets viewable and deletable. Key plaintext is shown once, at issuance, and never
stored by the console. Beside it the console tells developers to set
`OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the key).
assets viewable and deletable; it can't be undone, so its confirmation says so,
counts the active keys it revokes and, when there are any, asks for the
project's name. Key plaintext is shown once, at issuance, and never
stored by the console. Beside it, and without the key on an active project's
page, the console tells developers to set `OPENAI_BASE_URL` (the installation's
API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python
samples that list Agents and create a Session.
- **Web API only.** Every read and write goes through `/core/v1/**`. The console
holds no API key and sends nothing to `/v1`.
- **No asset writes except delete.** Assets are created and changed only by
Expand Down
19 changes: 18 additions & 1 deletion apps/web/e2e/access.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";

import { expectManagementBoundary, FIXTURE_CORE_KEY, openConsole, resetFixture } from "./console";
import { expectManagementBoundary, FIXTURE_CORE_KEY, issueKeys, openConsole, resetFixture } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

Expand Down Expand Up @@ -75,9 +75,24 @@ test("opens a fresh install on the Overview's Getting started: a project and its
await expect(issued.getByLabel("New key my-app")).toHaveValue(/fixture-secret/);
await issued.getByRole("button", { name: "I've saved this key" }).click();
await expect(page.getByLabel("New key my-app")).toHaveCount(0);
// More keys make the project's key table tall, so its page settles well below where it first draws.
const projectId = new URLSearchParams(new URL(page.url()).hash.split("?")[1]).get("id")!;
await issueKeys(request, projectId, Array.from({ length: 12 }, (_, index) => `app-${index + 1}`));

await page.getByRole("button", { name: "Overview", exact: true }).click();
// A new page load, so the project's page reads its keys again rather than showing the ones it has.
await page.reload();
await expect(step("Create a project and issue a key")).toContainText("Done");
// The first Session: the project's call samples, which never hold the key.
await step("Run the first Session").getByRole("button", { name: "See how to call" }).click();
await expect(page.getByRole("heading", { name: "My app", level: 1 })).toBeVisible();
const call = page.getByRole("region", { name: "How to call" });
await expect(call.getByRole("heading", { name: "How to call" })).toBeFocused();
// Once the keys have drawn, the card's heading is still in view, under the page header.
await expect(page.getByRole("table", { name: "Keys of My app" })).toContainText("app-12");
await expect(call.getByRole("heading", { name: "How to call" })).toBeInViewport();
await expect(page.getByRole("heading", { name: "My app", level: 1 })).toBeInViewport();
await expect(call.getByLabel("Shell", { exact: true })).toHaveText('export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY="<project API key>"');
const stored = await browserStorage(page);
expect(stored).not.toContain("fixture-secret");
expect(stored).not.toContain(FIXTURE_CORE_KEY);
Expand All @@ -92,6 +107,8 @@ test("leads from Getting started to the default model, and counts it done once t
// It arrives on the default harness's action.
const set = page.getByRole("button", { name: "Set the default model for Codex" });
await expect(set).toBeFocused();
// Only the page body scrolls to it: the page header stays in view.
await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeInViewport();
await set.click();
const form = page.getByRole("dialog", { name: "Set default model for Codex" });
await form.getByLabel("Base URL").fill("https://model.example/v1");
Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export async function archiveProject(request: APIRequestContext, projectId: stri
await request.post(`${fixture}/core/v1/projects/${projectId}/archive`, { headers: { cookie: "core_console=fixture-session" } });
}

/** Issues keys in a project behind the console's back, as another administrator would. */
export async function issueKeys(request: APIRequestContext, projectId: string, names: readonly string[]) {
for (const name of names) await request.post(`${fixture}/core/v1/projects/${projectId}/keys`, { headers: { cookie: "core_console=fixture-session" }, data: { name } });
}

/** Writes the browser sent through the console, as "METHOD /path". */
export async function writes(request: APIRequestContext): Promise<string[]> {
return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes;
Expand Down
36 changes: 36 additions & 0 deletions apps/web/e2e/monitoring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ test("opens a Session's conversation from the Session log, read-only", async ({
await expect(page.getByRole("textbox")).toHaveCount(0);
});

test("keeps a failed Session's reason in sight in the Session log and on its page", async ({ page, request }) => {
const reasons = /Sandbox allocation failed: node unavailable\.|Model provider returned 429 Too Many Requests\.|Tool call timed out after 300 s\./;
await page.setViewportSize({ width: 1280, height: 800 });
await openConsole(page, request, "sessions");
await page.getByRole("radio", { name: /^Failed/ }).click();
const row = page.getByRole("row").filter({ hasText: reasons }).first();
const reason = (await row.getByText(reasons).textContent())!;
await expect(row.getByText(reason)).toHaveAttribute("title", reason);
// The reason never widens the table: its last column, Delete, stays in view without scrolling sideways.
await expect(row.getByRole("button", { name: /^Delete Session / })).toBeInViewport();
await row.getByRole("button", { name: /^Open Session / }).click();
await expect(page.getByLabel("Session facts")).toContainText(reason);
await page.getByRole("button", { name: /^Jump to (the|a) failed Turn/ }).click();
await expect(page.locator("li.chat-turn:focus")).toContainText("Failed");
});

test("opens an Agent's Sessions from its failed Turns on Agent metrics", async ({ page, request }) => {
await openConsole(page, request, "agent-metrics");
const link = /^\d+ failed Turns? — open .+'s Sessions$/;
const row = page.getByRole("row").filter({ has: page.getByRole("button", { name: link }) }).first();
const failed = row.getByRole("button", { name: link });
await expect(failed).toHaveAttribute("title", link);
const agent = (await row.getByRole("rowheader").innerText()).trim();
const project = (await row.getByRole("cell").first().innerText()).trim();
await failed.click();
await expect(page.getByRole("heading", { name: "Session log", level: 1 })).toBeVisible();
// The log's own project and Agent filters, with every status.
await expect(page.getByRole("combobox", { name: "Project", exact: true })).toHaveText(project);
await expect(page.getByRole("combobox", { name: "Agent", exact: true })).toHaveText(agent);
await expect(page.getByRole("radio", { name: /^All/ })).toBeChecked();
await expect(page.getByRole("searchbox")).toHaveValue("");
const sessions = page.getByRole("table", { name: "Session log" }).getByRole("row");
await expect(sessions.nth(1)).toContainText(agent);
await expect(sessions.filter({ hasNotText: agent })).toHaveCount(1);
});

test("shows a self-hosted Session's install command, issues its credential once, and revokes and restores it", async ({ page, request }) => {
await openConsole(page, request, "sessions");
await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click();
Expand Down
15 changes: 15 additions & 0 deletions apps/web/e2e/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ test("creates a project, shows a new key once, revokes it and archives the proje
const call = issued.getByRole("region", { name: "How to call" });
await expect(call.getByLabel("Shell", { exact: true })).toHaveText(`export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY=${key}`);
await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents"');
await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents/sessions"');
await expect(call.getByLabel("Python", { exact: true })).toContainText("pip install openai==3.13.0");
await issued.getByRole("button", { name: "I've saved this key" }).click();
await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci");
Expand All @@ -36,6 +37,20 @@ test("creates a project, shows a new key once, revokes it and archives the proje
await expect(page.getByText("Archived").first()).toBeVisible();
});

test("archives a project with active keys only once its name is typed", async ({ page, request }) => {
await openConsole(page, request, "projects?id=proj_7f3a91c2");
await expect(page.getByRole("region", { name: /^Keys/ }).getByRole("heading")).toHaveText("Keys 3 active · 1 revoked");
await page.getByRole("button", { name: "Archive Production" }).click();
const dialog = page.getByRole("dialog", { name: "Archive project" });
await expect(dialog).toContainText("This can't be undone");
await expect(dialog).toContainText("Its 3 active keys are revoked at once");
const archive = dialog.getByRole("button", { name: "Archive" });
await expect(archive).toBeDisabled();
await dialog.getByLabel("Type Production to confirm").fill("Production");
await archive.click();
await expect(page.getByText("Archived").first()).toBeVisible();
});

test("reports an unconfirmed key issue and never replays it", async ({ page, request }) => {
await openConsole(page, request, "projects");
await page.getByRole("button", { name: "Open Production" }).click();
Expand Down
Loading