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
64 changes: 29 additions & 35 deletions apps/web/src/bench-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,18 @@ function membership(
}

describe("resolveSelection", () => {
test("a workbench tenant sorting first is skipped in favor of the first workbench", () => {
test("a named tenant sorting first wins — no kinds lookup to skip it", () => {
const memberships = [
membership({ tenantId: "tnt_workbench", tenantName: "Myra" }),
membership({ tenantId: "tnt_first", tenantName: "Myra" }),
membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }),
];
const workbenchTenantIds = new Set(["tnt_workbench"]);

const resolved = resolveSelection(memberships, null, workbenchTenantIds);
const resolved = resolveSelection(memberships, null);

expect(resolved?.tenantId).toBe("tnt_bench");
expect(resolved?.tenantId).toBe("tnt_first");
});

test("a raw-id tenant sorting first is skipped even before workbenchTenantIds resolves", () => {
test("a raw-id tenant sorting first is skipped in favor of the first named bench", () => {
const memberships = [
membership({
tenantId: "tnt_raw",
Expand All @@ -39,57 +38,52 @@ describe("resolveSelection", () => {
membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }),
];

const resolved = resolveSelection(memberships, null, new Set());
const resolved = resolveSelection(memberships, null);

expect(resolved?.tenantId).toBe("tnt_bench");
});

test("a stored selection that still names a workbench wins over the first membership", () => {
test("a stored selection that still names a bench wins over the first membership", () => {
const memberships = [
membership({ tenantId: "tnt_bench_a", tenantName: "A" }),
membership({ tenantId: "tnt_bench_b", tenantName: "B" }),
];

const resolved = resolveSelection(memberships, "tnt_bench_b", new Set());
const resolved = resolveSelection(memberships, "tnt_bench_b");

expect(resolved?.tenantId).toBe("tnt_bench_b");
});

test("a stored selection that turns out to be a workbench self-corrects to a workbench", () => {
test("a stored selection naming a raw-id tenant falls through to the first named bench", () => {
const memberships = [
membership({ tenantId: "tnt_workbench", tenantName: "Myra" }),
membership({
tenantId: "tnt_raw",
tenantName: "ins_71f5c0c9c30026859014ccd9df8b1",
}),
membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }),
];

// Before the kinds lookup resolves, the stored workbench id still
// matches — nothing to distinguish it yet.
const beforeKinds = resolveSelection(
memberships,
"tnt_workbench",
new Set(),
);
expect(beforeKinds?.tenantId).toBe("tnt_workbench");

// Once workbenchTenantIds arrives, the same stored id is re-evaluated
// and no longer honored.
const afterKinds = resolveSelection(
memberships,
"tnt_workbench",
new Set(["tnt_workbench"]),
);
expect(afterKinds?.tenantId).toBe("tnt_bench");
const resolved = resolveSelection(memberships, "tnt_raw");

expect(resolved?.tenantId).toBe("tnt_bench");
});

test("a stored selection for a tenant no longer in memberships falls through", () => {
const memberships = [
membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }),
];

const resolved = resolveSelection(memberships, "tnt_gone");

expect(resolved?.tenantId).toBe("tnt_bench");
});

test("undefined when every membership is a workbench or raw-id tenant", () => {
test("undefined when every membership is a raw-id tenant", () => {
const memberships = [
membership({ tenantId: "tnt_workbench", tenantName: "Myra" }),
membership({ tenantId: "tnt_raw", tenantName: "tnt_raw" }),
];

const resolved = resolveSelection(
memberships,
null,
new Set(["tnt_workbench"]),
);
const resolved = resolveSelection(memberships, null);

expect(resolved).toBeUndefined();
});
Expand Down
65 changes: 21 additions & 44 deletions apps/web/src/bench-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
// bench (the chat page, the benches page, the header switcher) reads this
// context instead of re-deriving "membership[0]" on its own.

import {
classifyBenchMembership,
listWorkbenchTenantIds,
} from "@corbits/bench-ui";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { isRawIdentifier } from "@corbits/bench-ui";
import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";

Expand Down Expand Up @@ -51,37 +48,35 @@ export type BenchState = {
export const BenchContext = createContext<BenchState | null>(null);

/** The membership this context currently treats as selected: the stored
* choice if it still names a bench the account belongs to *and* still
* classifies as a bench, otherwise the first bench-kind
* membership — the same personal-bench convention `chat-page.tsx` used
* to apply inline, minus the workbench and raw-id tenancies that same
* unfiltered "first membership" pick let default in.
* choice if it still names a bench the account belongs to, otherwise the
* first named membership — the same personal-bench convention
* `chat-page.tsx` used to apply inline, minus the raw-id tenancies that
* same unfiltered "first membership" pick let default in.
*
* `workbenchTenantIds` may still be empty because the kinds lookup
* hasn't resolved yet — never blocks boot on it (`isRawIdentifier`
* inside `classifyBenchMembership` catches a raw-id tenant with no
* fetch at all). A stored selection that was picked before the fetch
* resolved, and turns out to be a workbench, is re-evaluated on every
* call, so the default self-corrects once `workbenchTenantIds` arrives
* rather than sticking with whatever `resolveSelection` picked first. */
* A bench is a membership with a human-assigned name: a tenant whose name
* is a raw platform id never hosts the shell. The server-side kinds lookup
* (`POST /api/workbench-tenancies/kinds`) is gone with chat's
* `workbench_tenancy` table — child-tenant exclusion now lives in the
* client-held workbench list (`needs-list.ts`'s `childTenantStore`), not in
* this selector.
*
* True for a membership the shell may treat as a bench: named, never raw. */
export function isBenchMembership(membership: Principal): boolean {
return !isRawIdentifier(membership.tenantName);
}

export function resolveSelection(
memberships: readonly Principal[],
stored: string | null,
workbenchTenantIds: ReadonlySet<string>,
): Principal | undefined {
const storedMatch =
stored !== null
? memberships.find((m) => m.tenantId === stored)
: undefined;
if (
storedMatch !== undefined &&
classifyBenchMembership(storedMatch, workbenchTenantIds) === "bench"
) {
if (storedMatch !== undefined && isBenchMembership(storedMatch)) {
return storedMatch;
}
return memberships.find(
(m) => classifyBenchMembership(m, workbenchTenantIds) === "bench",
);
return memberships.find((m) => isBenchMembership(m));
}

export function BenchProvider({ children }: { readonly children: ReactNode }) {
Expand All @@ -91,27 +86,9 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) {
readStoredTenantId(),
);

const tenantIds =
memberships.kind === "ready"
? memberships.data.data.map((membership) => membership.tenantId)
: [];
// Never gates boot: `resolveSelection` below runs against `new Set()`
// until this resolves, catching only the raw-id case immediately —
// the workbench case self-corrects once `workbenchTenancyKinds.data` lands
// and this component re-renders.
const workbenchTenancyKinds = useQuery({
queryKey: meKeys.workbenchTenancyKinds(tenantIds),
queryFn: () => listWorkbenchTenantIds(tenantIds),
enabled: tenantIds.length > 0,
});

const resolved =
memberships.kind === "ready"
? resolveSelection(
memberships.data.data,
stored,
workbenchTenancyKinds.data ?? new Set(),
)
? resolveSelection(memberships.data.data, stored)
: undefined;

useEffect(() => {
Expand Down
192 changes: 192 additions & 0 deletions apps/web/src/client-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";

import type { ClientLogger } from "@corbits/client-log";

import {
bootstrapClientSession,
logBootstrapResult,
logBootstrapThrown,
} from "./client-bootstrap";
import type { ClientBootstrapResult } from "./client-bootstrap";
import type { StockHub } from "./needs-converge";

function memoryStorage() {
const rows = new Map<string, string>();
return {
getItem: (key: string) => rows.get(key) ?? null,
setItem: (key: string, value: string) => {
rows.set(key, value);
},
};
}

test("session bootstrap drives the portable client manifest", async () => {
const calls: string[] = [];
const hub: StockHub = {
listMyPrincipals: () => {
calls.push("listMyPrincipals");
return Promise.resolve([
{
principalId: "prn_user",
tenantId: "tnt_primary",
tenantName: "Ada",
tenantSlug: "ada",
kind: "user",
status: "active",
roles: [{ id: "role_owner", name: "owner" }],
},
]);
},
getTenant: (id) => {
calls.push(`getTenant:${id}`);
return Promise.resolve({
id,
name: "Ada",
slug: "ada",
parentId: null,
});
},
listPrincipals: (tenantId) => {
calls.push(`listPrincipals:${tenantId}`);
return Promise.resolve([
{
id: "prn_user",
tenantId,
kind: "user",
refId: "usr_1",
displayName: "Ada",
email: "ada@example.com",
status: "active",
roles: [{ id: "role_owner", name: "owner" }],
},
{
id: "prn_myra",
tenantId,
kind: "workflow",
refId: "assistant",
displayName: "Myra",
status: "active",
roles: [],
},
]);
},
createTenant: () => Promise.reject(new Error("unexpected create")),
inviteMember: () => Promise.reject(new Error("unexpected invite")),
deployWorkflow: () => Promise.reject(new Error("unexpected deploy")),
sendRunMail: () => Promise.reject(new Error("unexpected send")),
listRunMail: () => Promise.resolve([]),
searchAgentMailbox: () =>
Promise.reject(new Error("unexpected mailbox search")),
readMailThread: () => Promise.reject(new Error("unexpected thread read")),
};

const result = await bootstrapClientSession(
{ id: "usr_1", name: "Ada", email: "ada@example.com" },
{
hub,
storage: memoryStorage(),
hubScope: "https://hub.example",
},
);

// Myra is present top-level and there are no workbenches: no tenants to
// create, no DMs to project — DMs derive from threads, never tenants.
expect(result).toEqual({
kind: "ready",
primaryTenantId: "tnt_primary",
createdTenantIds: [],
primaryThreads: [],
});
expect(calls).toEqual([
"listMyPrincipals",
"getTenant:tnt_primary",
"listPrincipals:tnt_primary",
]);
});

describe("logBootstrapResult", () => {
function recordingLogger() {
const entries: { level: string; message: string; data?: unknown }[] = [];
const log: ClientLogger = {
debug: () => undefined,
info: (message, data) => {
entries.push({ level: "info", message, data });
},
warn: (message, data) => {
entries.push({ level: "warn", message, data });
},
error: () => undefined,
};
return { log, entries };
}

test("logs ready at info with the converged ids", () => {
const { log, entries } = recordingLogger();
logBootstrapResult(log, {
kind: "ready",
primaryTenantId: "tnt_primary",
createdTenantIds: ["tnt_atlas"],
primaryThreads: [],
});
expect(entries).toEqual([
{
level: "info",
message: "Portable client bootstrap converged",
data: {
primaryTenantId: "tnt_primary",
createdTenantIds: ["tnt_atlas"],
},
},
]);
});

test("logs a stock gap at warn with its capability note", () => {
const { log, entries } = recordingLogger();
const result: ClientBootstrapResult = {
kind: "error",
code: "stock-capability-missing",
capability: "project-workflow-principal",
message: "cannot project",
gap: "needs upstream work",
};
logBootstrapResult(log, result);
expect(entries).toEqual([
{
level: "warn",
message: "Portable client bootstrap waiting on stock capability",
data: {
capability: "project-workflow-principal",
gap: "needs upstream work",
},
},
]);
});

test("logs other failures and thrown errors at warn, never throwing", () => {
const { log, entries } = recordingLogger();
logBootstrapResult(log, {
kind: "error",
code: "client-config-missing",
message: "no Myra entry",
});
logBootstrapThrown(log, new Error("boom"));
logBootstrapThrown(log, "bare string");
expect(entries).toEqual([
{
level: "warn",
message: "Portable client bootstrap failed",
data: { message: "no Myra entry" },
},
{
level: "warn",
message: "Portable client bootstrap threw",
data: { message: "boom" },
},
{
level: "warn",
message: "Portable client bootstrap threw",
data: { message: "bare string" },
},
]);
});
});
Loading
Loading