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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ check:structural report-error -- --write-baseline` after fixing (or
`pull_request` job reads `pull_request.base.sha` from
`GITHUB_EVENT_PATH`; locally it falls back to the merge base with
`origin/main`) and flags any tool package whose `src/` differs from that
base while its version stayed the same. With no base ref available
(no `origin/main`, no pull-request event) it no-ops, deferring to CI as the
authoritative run.
base while its version stayed the same. It also runs the publisher's
historical freshness check against the commit that introduced each
package's current version, including uncommitted and untracked source.
This historical check still runs when no base ref is available.
- Every package needs a `LICENSE` file (canonical LGPL-2.1-or-later text,
copy from any existing `packages/*/LICENSE`) — the licenses check fails
without one.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ degradation story.
`bun run dev` validates your `.env` (reporting every missing or malformed
value at once), verifies the database in `DATABASE_URL` is reachable and
actually speaks Postgres, applies any pending platform migrations, builds
the web UI if it has not been built yet, and starts the hub and one sidecar
together. Every required setting lives in `.env.example` with its expected
shape, and the administrator account (`HUB_ADMIN_EMAIL` / `HUB_ADMIN_PASSWORD`,
the web UI if it has not been built yet, and starts the hub. The hub
provisions authenticated sidecars on demand using the configured backend.
Every required setting lives in `.env.example` with its expected shape, and the administrator account (`HUB_ADMIN_EMAIL` / `HUB_ADMIN_PASSWORD`,
defaulting to alice@example.com / password123 when unset)
is seeded so you can sign in immediately.

Expand Down
41 changes: 31 additions & 10 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// so a broken provisioning call never leaves the user silently benchless.

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";
import type { SupportedCredentialProvider } from "@corbits/connections/credential-test";

const ProvisionResult = type({
Expand Down Expand Up @@ -628,31 +629,51 @@ const ProvisioningStatus = type({
* converge in the background.
* - `preparing` — Myra is not live yet; this is the only state worth
* holding someone on a loader for.
* - `unknown` — we could not tell (offline, a hiccup, an account with no
* personal bench). Never rendered as either progress or failure.
* - `error` — readiness could not be checked; show the message and allow retry.
*/
export type AgentReadiness =
| { readonly kind: "ready" }
| { readonly kind: "chat-ready" }
| { readonly kind: "preparing" }
| { readonly kind: "unknown" };
| { readonly kind: "error"; readonly message: string };

/**
* Where this account's agents stand right now. Cheap and read-only, so a
* surface that has to wait may poll it on a short interval.
*/
export async function fetchAgentReadiness(): Promise<AgentReadiness> {
export async function fetchAgentReadiness(
tenantId: string,
): Promise<AgentReadiness> {
try {
const response = await fetch("/api/onboarding/provisioning-status");
if (!response.ok) return { kind: "unknown" };
const body: unknown = await response.json().catch(() => null);
const response = await fetch(
`/api/onboarding/provisioning-status?${new URLSearchParams({ tenantId })}`,
);
const body: unknown = await response.json();
if (!response.ok) {
const envelope = ErrorEnvelope(body);
if (!(envelope instanceof type.errors)) {
return {
kind: "error",
message: `${envelope.error.userMessage} Reference: ${envelope.error.refId}`,
};
}
throw new Error(`Agent readiness request failed (${response.status})`);
}
const parsed = ProvisioningStatus(body);
if (parsed instanceof type.errors) return { kind: "unknown" };
if (parsed instanceof type.errors)
throw new Error("Invalid agent readiness response");
if (parsed.kind === "ready") return { kind: "ready" };
return parsed.setupAgentReady
? { kind: "chat-ready" }
: { kind: "preparing" };
} catch {
return { kind: "unknown" };
} catch (cause) {
const refId = reportError(cause, {
operation: "agent_readiness",
tenantId,
});
return {
kind: "error",
message: `Couldn't check your agent. Try again. Reference: ${refId}`,
};
}
}
6 changes: 5 additions & 1 deletion apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,12 @@ export function HomeRoute({
// (CL-6780), so a not-ready status with no credential is an honest
// next step, not a forever spin on "getting ready".
const awaitFirstWorkbench = () => {
void fetchAgentReadiness().then((readiness) => {
void fetchAgentReadiness(selectedTenantId).then((readiness) => {
if (cancelled) return;
if (readiness.kind === "error") {
setState({ kind: "error", message: readiness.message });
return;
}
if (readiness.kind === "ready" || readiness.kind === "chat-ready") {
void listAgentDefinitions(selectedTenantId).then((definitions) => {
if (cancelled) return;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,13 @@ export function NewWorkbenchPickerRoute() {
cause instanceof WorkbenchPreconditionError &&
cause.kind === "setup-agent-missing"
) {
const readiness = await fetchAgentReadiness();
if (readiness.kind !== "ready" && readiness.kind !== "chat-ready") {
const readiness = await fetchAgentReadiness(selectedTenantId);
if (readiness.kind === "error") {
toast(readiness.message);
setCreating(false);
return;
}
if (readiness.kind === "preparing") {
setCreating(false);
setStillSettingUp(true);
return;
Expand Down
47 changes: 44 additions & 3 deletions apps/web/test/home-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () =>
// rather than sending anyone to `/new`.
return json({ items: [] });
}
if (path === "/api/onboarding/provisioning-status") {
if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") {
return json({ kind: "ready", setupAgentReady: true });
}
if (path.includes("/workflows/definitions")) {
Expand Down Expand Up @@ -237,7 +237,7 @@ describe("the wait right after connecting a provider", () => {
if (path === "/api/tenants/tnt_1/credentials") {
return json({ data: [{ status: "active" }], nextCursor: null });
}
if (path === "/api/onboarding/provisioning-status") {
if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") {
statusReads += 1;
state.statusCalls += 1;
const setupAgentReady = statusReads > readyAfter;
Expand Down Expand Up @@ -279,6 +279,47 @@ describe("the wait right after connecting a provider", () => {
});
}

test("shows a readiness error and retries without pretending the agent is preparing", async () => {
let failReadiness = true;
stubFetch((path, method) => {
if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE);
if (path.endsWith("/chat/workbenches") && method === "GET")
return json({ items: [] });
if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") {
return failReadiness
? json(
{
error: {
code: "bench_unavailable",
userMessage: "This workspace is unavailable. Try again.",
refId: "ref_readiness",
},
},
409,
)
: json({ kind: "ready", setupAgentReady: true });
}
const launch = respondMyraDmLaunch(path, method);
if (launch !== null) return launch;
throw new Error(`unexpected fetch: ${method} ${path}`);
});
const navigated: string[] = [];
await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated });
for (let i = 0; i < 20; i++) await settle();
expect(container?.textContent).toContain(
"This workspace is unavailable. Try again.",
);
expect(container?.textContent).not.toContain("Preparing your agent");
const retry = Array.from(container?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "Retry",
);
expect(retry).toBeDefined();
failReadiness = false;
await act(async () => retry?.click());
for (let i = 0; i < 20 && navigated.length === 0; i++) await settle();
expect(navigated).toEqual(["/w/chan_myra_dm"]);
});

test("shows the warm loader while Myra is still coming online — never a count", async () => {
benchWhereMyraArrivesAfter(99);
const navigated: string[] = [];
Expand Down Expand Up @@ -308,7 +349,7 @@ describe("the wait right after connecting a provider", () => {
if (path === "/api/tenants/tnt_1/credentials") {
return json({ data: [], nextCursor: null });
}
if (path === "/api/onboarding/provisioning-status") {
if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") {
return json({
kind: "provisioning",
setupAgentReady: false,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/test/new-workbench-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ describe("NewWorkbenchPickerRoute", () => {
if (path.includes("/workflows/definitions")) {
return json({ data: [], nextCursor: null });
}
if (path.endsWith("/api/onboarding/provisioning-status")) {
if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) {
return json({
kind: "provisioning",
tenantId: "tnt_1",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/test/toast-single-system.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe("the one toast system (CL-6372)", () => {
if (path.includes("/workflows/definitions")) {
return Promise.resolve(json({ data: [], nextCursor: null }));
}
if (path.endsWith("/api/onboarding/provisioning-status")) {
if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) {
return Promise.resolve(
json({
kind: "provisioning",
Expand Down
2 changes: 1 addition & 1 deletion packages/access-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@corbits/access-tools",
"private": true,
"description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz",
"version": "0.0.1",
"version": "0.0.2",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/catalog-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@corbits/catalog-tools",
"private": true,
"description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.",
"version": "0.0.4",
"version": "0.0.5",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/interaction-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@corbits/interaction-tools",
"private": true,
"description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block, then ends the turn — the user's answer arrives as the agent's next message, not this call's result",
"version": "0.0.7",
"version": "0.0.8",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
45 changes: 38 additions & 7 deletions packages/onboarding/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
import type { AppEnv } from "@intx/hub-api";
import { createExpiringMap } from "@corbits/collections";
import { createNoopCredentialCipher } from "@intx/crypto";
import { CredentialResponse, paginatedSchema } from "@intx/types";
import {
CredentialResponse,
PrincipalSummary,
paginatedSchema,
} from "@intx/types";
import type { CredentialCipher } from "@intx/types";
import {
supportedCredentialProviders,
Expand Down Expand Up @@ -61,6 +65,8 @@ import { exchangeCodeForKey } from "./openrouter-connect";
import { exchangeCodeForToken as exchangeHuggingFaceCodeForToken } from "./huggingface-connect";
import type { ProviderHealthStore } from "@corbits/connections/provider-health";

const ProvisioningStatusQuery = type({ tenantId: "string > 0" });

function assertNonEmpty<T>(arr: T[]): asserts arr is [T, ...T[]] {
if (arr.length === 0) {
throw new Error("expected a non-empty array");
Expand Down Expand Up @@ -333,7 +339,7 @@ export function createOnboardingRoutes(
*/
async function provisioningStatus(
cookies: string[],
tenant: PersonalTenant,
tenant: Pick<PersonalTenant, "tenantId" | "tenantSlug">,
): Promise<ProvisioningStatusBody> {
const { deployed, pending } = await seededWorkflowStatus(
api,
Expand Down Expand Up @@ -952,21 +958,46 @@ export function createOnboardingRoutes(
);
}

const query = ProvisioningStatusQuery(c.req.query());
if (query instanceof type.errors) {
return c.json(
makeErrorEnvelope({
code: "invalid_request",
userMessage: "Select a workspace to check its agents.",
}),
400,
);
}

const cookies = cookiesFromHeader(c.req.header("cookie"));
// Known once the tenant lookup below resolves; a lookup failure
// itself has no tenant yet.
let tenantId: string | undefined;
try {
const expectedSlug = personalTenantSlug(user.email, user.id);
const tenant = await findPersonalTenant(api, cookies, expectedSlug);
const response = await api(
"GET",
"/api/me/principals",
undefined,
cookies,
);
const principals = parseAs(
paginatedSchema(PrincipalSummary),
response.data,
"principals response",
);
const tenant = principals.data.find(
(principal) =>
principal.tenantId === query.tenantId &&
principal.status === "active",
);
if (!tenant) {
return c.json(
makeErrorEnvelope({
code: "no_personal_bench",
code: "bench_unavailable",
userMessage:
"No personal bench was found for this account yet. Reload and try again.",
"You no longer have access to this workspace. Select another workspace.",
}),
409,
403,
);
}
tenantId = tenant.tenantId;
Expand Down
33 changes: 30 additions & 3 deletions packages/onboarding/test/complete-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ describe("completeCredentialSetup", () => {
providerId: string;
priority: number;
}[] = [];
const providers: Row[] = [];
const providers: (Row & { apiBaseUrl: string | null })[] = [];
const credentials: Row[] = [];
let assetCreatePosts = 0;
let deploymentCreatePosts = 0;
Expand Down Expand Up @@ -1120,18 +1120,44 @@ describe("completeCredentialSetup", () => {
};
}
if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) {
const name = (body as { name: string }).name;
const { name, apiBaseUrl = null } = body as {
name: string;
apiBaseUrl?: string;
};
const existing = providers.find((p) => p.name === name);
if (existing) return { status: 409, data: {}, cookies: [] };
const id = `prv_${name}`;
providers.push({ name, id });
providers.push({ name, id, apiBaseUrl });
return {
status: 201,
data: {
id,
tenantId: TENANT_ID,
name,
plugin: "anthropic",
apiBaseUrl,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
cookies: [],
};
}
if (
method === "PATCH" &&
path === `/api/tenants/${TENANT_ID}/providers/prv_anthropic`
) {
const provider = providers.find((p) => p.id === "prv_anthropic");
if (provider === undefined)
throw new Error("provider must exist before updating it");
provider.apiBaseUrl = (body as { apiBaseUrl: string }).apiBaseUrl;
return {
status: 200,
data: {
id: provider.id,
tenantId: TENANT_ID,
name: provider.name,
plugin: "anthropic",
apiBaseUrl: provider.apiBaseUrl,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
Expand All @@ -1150,6 +1176,7 @@ describe("completeCredentialSetup", () => {
tenantId: TENANT_ID,
name: p.name,
plugin: "anthropic",
apiBaseUrl: p.apiBaseUrl,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
})),
Expand Down
Loading
Loading