diff --git a/apps/web/src/client-bootstrap.ts b/apps/web/src/client-bootstrap.ts index b1aa2e648..9792d3808 100644 --- a/apps/web/src/client-bootstrap.ts +++ b/apps/web/src/client-bootstrap.ts @@ -1,26 +1,12 @@ // Uses only stock routes — no workbench server proxies, tables, or mounts. -// Persisted child tenant ids let a reinstall reclaim by id, never by slug. -import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; +import { findOwnedTenants, type StockHub } from "./needs-converge"; -import type { ClientLogger } from "@/lib/client-log"; - -import { - buildNeedsList, - childTenantStore, - type StringStorage, - type WorkflowDeployInput, -} from "./needs-list"; -import { - convergeNeedsList, - createFetchStockHub, - findOwnedTenants, - StockHubCapabilityError, - StockHubRequestError, - type PrimaryThread, - type StockHub, - type StockHubCapability, -} from "./needs-converge"; +export type ClientBootstrapAccount = { + readonly id: string; + readonly name: string; + readonly email: string; +}; /** Derives a stable, readable slug for the primary tenant this account is * about to mint — never random, so a retry after a dropped response @@ -34,8 +20,8 @@ function primaryTenantSlug(account: ClientBootstrapAccount): string { return base !== undefined && base.length > 0 ? base : `home-${account.id}`; } -/** The "0→1" ruling's execution — never hub boot, never a CLI. Never - * mints a second root: an account that already owns one is left alone. */ +/** Never hub boot, never a CLI. Never mints a second root: an account that + * already owns one is left alone. */ export async function ensurePrimaryTenant( account: ClientBootstrapAccount, hub: StockHub, @@ -47,183 +33,3 @@ export async function ensurePrimaryTenant( slug: primaryTenantSlug(account), }); } - -// Resolved from the catalog at runtime, never hardcoded at the call site. -export function resolveWorkerDefinitionRefId(): string | undefined { - return WORKFLOW_CATALOG.find((entry) => entry.displayName === "Worker")?.assetName; -} - -export type ClientBootstrapAccount = { - readonly id: string; - readonly name: string; - readonly email: string; -}; - -export type ClientBootstrapDeps = { - readonly hub: StockHub; - readonly storage: StringStorage; - /** The hub origin this browser talks to — scopes persisted ids per hub. */ - readonly hubScope: string; - /** Override for the catalog-resolved Worker definition refId. */ - readonly workerDefinitionRefId?: string; - /** Exact stock deployment inputs for Worker when she is absent. Caller - * config only — never synthesized here. */ - readonly workerDeploy?: WorkflowDeployInput; -}; - -export type ClientBootstrapResult = - | { - readonly kind: "ready"; - readonly primaryTenantId: string; - readonly createdTenantIds: readonly string[]; - readonly primaryThreads: readonly PrimaryThread[]; - } - | { - readonly kind: "error"; - readonly code: "stock-capability-missing"; - readonly capability: StockHubCapability; - readonly message: string; - /** What stock Interchange cannot do yet, in operator language. */ - readonly gap: string; - } - | { - readonly kind: "error"; - readonly code: "stock-hub-request-failed"; - readonly operation: string; - readonly status: number | undefined; - readonly message: string; - } - | { - readonly kind: "error"; - readonly code: "client-config-missing"; - readonly message: string; - }; - -/** Operator-language note per missing stock capability — the reason a - * convergence stops, and what has to change upstream before it can - * proceed. Surfaced alongside the typed error, never a silent skip. */ -export const UPSTREAM_GAP_NOTES: Record = { - "primary-tenant-bootstrap": - "Sign-in is expected to leave exactly one owned top-level home behind, but this session shows zero or several.", - "deploy-workflow-inputs": - "The worker is absent and the client was not given the exact stock source and offering ids — supply workerDeploy from client config.", - "project-workflow-principal": - "Stock Interchange cannot carry a workflow identity into a child workbench by refId, so workbench member setup waits on an upstream capability.", - "principal-roles": - "The stock member invite route cannot assign the requested child-workbench roles.", - "agent-mailbox-reads": - "Stock Interchange exposes no per-agent mailbox search, so participant-filtered thread derivation waits on a stock search route.", - "thread-fork-context": - "Stock Interchange exposes no full thread read, so sub-thread fork context waits on a stock thread route.", -}; - -export async function bootstrapClientSession( - account: ClientBootstrapAccount, - deps: ClientBootstrapDeps, -): Promise { - const workerDefinitionRefId = deps.workerDefinitionRefId ?? resolveWorkerDefinitionRefId(); - if (workerDefinitionRefId === undefined) { - return { - kind: "error", - code: "client-config-missing", - message: - "The client catalog names no worker workflow entry, so the bootstrap cannot identify the default worker.", - }; - } - const manifest = buildNeedsList({ - account: { id: account.id, name: account.name, email: account.email }, - workerDefinitionRefId, - ...(deps.workerDeploy === undefined ? {} : { workerDeploy: deps.workerDeploy }), - }); - const store = childTenantStore(deps.storage, deps.hubScope, account.id); - try { - // The common case never pays for a primary-tenant existence probe; - // only the first-signup gap triggers the mint-then-retry below. - const report = await convergeNeedsList(manifest, deps.hub, store).catch( - async (cause: unknown) => { - if ( - !(cause instanceof StockHubCapabilityError) || - cause.capability !== "primary-tenant-bootstrap" - ) { - throw cause; - } - await ensurePrimaryTenant(account, deps.hub); - return convergeNeedsList(manifest, deps.hub, store); - }, - ); - return { - kind: "ready", - primaryTenantId: report.primaryTenantId, - createdTenantIds: report.createdTenantIds, - primaryThreads: report.primaryThreads, - }; - } catch (cause) { - if (cause instanceof StockHubCapabilityError) { - return { - kind: "error", - code: "stock-capability-missing", - capability: cause.capability, - message: cause.message, - gap: UPSTREAM_GAP_NOTES[cause.capability], - }; - } - if (cause instanceof StockHubRequestError) { - return { - kind: "error", - code: "stock-hub-request-failed", - operation: cause.operation, - status: cause.status, - message: cause.message, - }; - } - throw cause; - } -} - -// A converged lane logs at info, any failure at warn — never shown, -// never gating the shell. -export function logBootstrapResult(log: ClientLogger, result: ClientBootstrapResult): void { - if (result.kind === "ready") { - log.info("Portable client bootstrap converged", { - primaryTenantId: result.primaryTenantId, - createdTenantIds: [...result.createdTenantIds], - }); - } else if (result.code === "stock-capability-missing") { - log.warn("Portable client bootstrap waiting on stock capability", { - capability: result.capability, - gap: result.gap, - }); - } else { - log.warn("Portable client bootstrap failed", { - message: result.message, - }); - } -} - -/** The thrown twin of logBootstrapResult — a bootstrap that rejects (rather - * than returning a typed error) is logged, never surfaced. */ -export function logBootstrapThrown(log: ClientLogger, error: unknown): void { - log.warn("Portable client bootstrap threw", { - message: error instanceof Error ? error.message : String(error), - }); -} - -// Both bootstrap entry points call this one helper so production ports -// can never drift between them. -export function runPortableClientBootstrap( - account: ClientBootstrapAccount, - overrides?: { - readonly workerDefinitionRefId?: string; - readonly workerDeploy?: WorkflowDeployInput; - }, -): Promise { - return bootstrapClientSession(account, { - hub: createFetchStockHub(), - storage: localStorage, - hubScope: window.location.origin, - ...(overrides?.workerDefinitionRefId === undefined - ? {} - : { workerDefinitionRefId: overrides.workerDefinitionRefId }), - ...(overrides?.workerDeploy === undefined ? {} : { workerDeploy: overrides.workerDeploy }), - }); -} diff --git a/apps/web/src/deferred-worker-setup.ts b/apps/web/src/deferred-worker-setup.ts deleted file mode 100644 index cfc14698a..000000000 --- a/apps/web/src/deferred-worker-setup.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Finishes the setup a skipped provider step left undone, once a model -// exists: the same publish-and-install the onboarding page runs. - -import { reportError } from "@corbits/error-sink"; - -import { runPortableClientBootstrap } from "./client-bootstrap"; -import { deployWorkerSource } from "./worker-deploy"; -import { createFetchStockHub, findOwnedTenants } from "./needs-converge"; -import { resolveExistingOffering } from "./onboarding/provider-connect-step"; -import { isProviderSkipped, setProviderSkipped } from "./provider-skip"; -import type { SessionUser } from "./session"; - -let inFlight: Promise | null = null; - -/** True once Worker is deployed and the skip flag is cleared; false while - * there is nothing to do yet (no skip, or no model connected). */ -export function finishDeferredWorkerSetup(user: SessionUser): Promise { - if (!isProviderSkipped(user.id)) return Promise.resolve(false); - inFlight ??= run(user).finally(() => { - inFlight = null; - }); - return inFlight; -} - -async function run(user: SessionUser): Promise { - try { - const owned = await findOwnedTenants(createFetchStockHub()); - const primary = owned.find((tenant) => tenant.parentId === null); - if (primary === undefined) return false; - const offering = await resolveExistingOffering(primary.id); - if (offering === null) return false; - const workerDeploy = await deployWorkerSource({ - tenantId: primary.id, - tenantDomain: primary.domain, - sourceOfferingIds: offering.sourceOfferingIds, - defaultSourceOfferingId: offering.defaultSourceOfferingId, - declaredSources: offering.declaredSources, - }); - const result = await runPortableClientBootstrap(user, { workerDeploy }); - if (result.kind !== "ready") return false; - setProviderSkipped(user.id, false); - return true; - } catch (cause) { - reportError(cause, { operation: "deferred_worker_setup" }); - return false; - } -} diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index f655b3ecb..4115b9620 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -3,23 +3,14 @@ import { type } from "arktype"; -import { WORKER_SOURCE_CONFIG } from "./worker-source"; import { childTenantStore, threadLinkStore, type ChildTenantStore, - type NeedsList, type StringStorage, type WorkflowDeployInput, } from "./needs-list"; -import { - buildForkReference, - deriveDmThreads, - deriveThreads, - type DmThread, - type Thread, - type ThreadMessage, -} from "./threads"; +import { buildForkReference, type ThreadMessage } from "./threads"; export type HubTenant = { id: string; @@ -84,10 +75,6 @@ export type StockHub = { // without it every subsequent read 403s. inviteMember(tenantId: string, input: { email: string; role: string }): Promise; deployWorkflow(tenantId: string, input: WorkflowDeployInput): Promise; - /** Whether a live (non-released, non-failed) deployment is anchored to - * the tenant's `workflow` asset of that name. Stock mints a deployment's - * principal only at its first run, so this is the deploy-time truth. */ - hasWorkflowDeployment(tenantId: string, assetName: string): Promise; sendRunMail(input: SendRunMailInput): Promise<{ messageId: string }>; listRunMail(input: { tenantId: string }): Promise; /** Stock per-agent mailbox search: participant-filtered thread derivation @@ -98,23 +85,8 @@ export type StockHub = { readMailThread(input: { messageId: string }): Promise; }; -export type HubSnapshot = { - primaryTenant: HubTenant; - workerDeployed: boolean; - primaryPrincipals: HubPrincipal[]; - childTenants: HubTenant[]; - childPrincipals: Record; -}; - -export type PrimaryThread = { - tenantId: string; - rootMessageId: string; - subThreads: Thread[]; -}; - export type StockHubCapability = | "primary-tenant-bootstrap" - | "deploy-workflow-inputs" | "project-workflow-principal" | "principal-roles" | "agent-mailbox-reads" @@ -160,190 +132,6 @@ export async function findOwnedTenants(hub: StockHub): Promise { ).filter((tenant): tenant is HubTenant => tenant !== null); } -export async function readHubSnapshot(hub: StockHub): Promise { - const tenants = await findOwnedTenants(hub); - const primaryCandidates = tenants.filter((tenant) => tenant.parentId === null); - const [primaryTenant] = primaryCandidates; - if (primaryCandidates.length !== 1 || primaryTenant === undefined) { - throw new StockHubCapabilityError( - "primary-tenant-bootstrap", - `Sign-in must leave exactly one owned top-level home; found ${primaryCandidates.length}.`, - ); - } - const childTenants = tenants.filter((tenant) => tenant.parentId === primaryTenant.id); - const [primaryPrincipals, childRows, workerDeployed] = await Promise.all([ - hub.listPrincipals(primaryTenant.id), - Promise.all( - childTenants.map(async (tenant) => [tenant.id, await hub.listPrincipals(tenant.id)] as const), - ), - hub.hasWorkflowDeployment(primaryTenant.id, WORKER_SOURCE_CONFIG.assetName), - ]); - return { - primaryTenant, - workerDeployed, - primaryPrincipals, - childTenants, - childPrincipals: Object.fromEntries(childRows), - }; -} - -function hasWorker(_manifest: NeedsList, snapshot: HubSnapshot): boolean { - return snapshot.workerDeployed; -} - -export type ConvergeReport = { - primaryTenantId: string; - createdTenantIds: string[]; - /** DMs derived from participant-filtered threads — never tenants. */ - directMessages: DmThread[]; - primaryThreads: PrimaryThread[]; -}; - -/** Re-resolves the stored tenant id before creating one — the store can - * point at a tenant the hub no longer has. */ -async function convergeWorkbenchTenant( - hub: StockHub, - store: ChildTenantStore, - snapshot: HubSnapshot, - workbench: NeedsList["workbenches"][number], -): Promise<{ tenantId: string; created: boolean }> { - const stored = store.load().find((row) => row.localId === workbench.localId); - const existing = snapshot.childTenants.find((tenant) => tenant.id === stored?.tenantId); - if (existing !== undefined && stored !== undefined) { - return { tenantId: existing.id, created: false }; - } - const created = await hub.createTenant({ - name: workbench.name, - slug: workbench.slug, - parentId: snapshot.primaryTenant.id, - }); - store.record({ - ...(stored ?? {}), - localId: workbench.localId, - tenantId: created.id, - kind: "workbench", - }); - for (const principal of workbench.principals) { - if (principal.email !== undefined) { - await hub.inviteMember(created.id, { - email: principal.email, - role: principal.roles[0] ?? "member", - }); - } - } - return { tenantId: created.id, created: true }; -} - -/** A recorded Message-ID means the send already happened — never resend, - * never mint a custom key. */ -async function convergePrimaryThread( - hub: StockHub, - store: ChildTenantStore, - workbench: NeedsList["workbenches"][number], - tenantId: string, -): Promise { - const stored = store.load().find((row) => row.localId === workbench.localId); - if (stored?.primaryThreadMessageId !== undefined) return; - if (workbench.initialMessage === undefined) return; - const sent = await hub.sendRunMail({ - tenantId, - runId: workbench.initialMessage.runId, - to: workbench.principals.flatMap((principal) => - principal.email === undefined ? [] : [principal.email], - ), - subject: workbench.name, - body: workbench.initialMessage.content, - }); - store.record({ - ...(stored ?? {}), - localId: workbench.localId, - tenantId, - kind: "workbench", - primaryThreadMessageId: sent.messageId, - }); -} - -export async function convergeNeedsList( - manifest: NeedsList, - hub: StockHub, - store: ChildTenantStore, - suppliedSnapshot?: HubSnapshot, - directMail: readonly ThreadMessage[] = [], -): Promise { - const snapshot = suppliedSnapshot ?? (await readHubSnapshot(hub)); - - // Gap checks first: nothing is written until every need is satisfiable. - if (!hasWorker(manifest, snapshot) && manifest.worker.deploy === undefined) { - throw new StockHubCapabilityError( - "deploy-workflow-inputs", - "The worker is absent and the client was not supplied the exact stock workflow source and offering ids.", - ); - } - - for (const workbench of manifest.workbenches) { - for (const principal of workbench.principals) { - if (principal.kind === "workflow") { - throw new StockHubCapabilityError( - "project-workflow-principal", - `Stock Interchange cannot carry workflow ${principal.refId} into a child workbench by refId.`, - ); - } - if (principal.roles.some((role) => role !== "member")) { - throw new StockHubCapabilityError( - "principal-roles", - "The stock member invite route cannot assign the requested child-workbench roles.", - ); - } - if (principal.email === undefined) { - throw new StockHubCapabilityError( - "project-workflow-principal", - `The stock member invite route cannot project user ${principal.refId} without an email address.`, - ); - } - } - } - - if (!hasWorker(manifest, snapshot) && manifest.worker.deploy !== undefined) { - await hub.deployWorkflow(snapshot.primaryTenant.id, manifest.worker.deploy); - } - - // Writes after gap checks, in needs-list order: child tenant, then its - // primary-thread first message. - const createdTenantIds: string[] = []; - for (const workbench of manifest.workbenches) { - const converged = await convergeWorkbenchTenant(hub, store, snapshot, workbench); - if (converged.created) createdTenantIds.push(converged.tenantId); - await convergePrimaryThread(hub, store, workbench, converged.tenantId); - } - - // Reads stay stock routes: split each workbench mailbox into its primary - // thread (first conversation.message sent in the child tenant, or the - // recorded primary id) plus forked sub-threads. - const primaryThreads: PrimaryThread[] = []; - for (const workbench of manifest.workbenches) { - const stored = store.load().find((row) => row.localId === workbench.localId); - if (stored === undefined) continue; - const mail = await hub.listRunMail({ tenantId: stored.tenantId }); - const grouped = deriveThreads(mail); - // The recorded id is authoritative; mail[0] is only a fallback for mail - // sent before it was recorded (see docs/chat-mail-threading.md). - const rootMessageId = stored.primaryThreadMessageId ?? mail[0]?.messageId; - if (rootMessageId === undefined) continue; - primaryThreads.push({ - tenantId: stored.tenantId, - rootMessageId, - subThreads: grouped.filter((thread) => thread.rootMessageId !== rootMessageId), - }); - } - - return { - primaryTenantId: snapshot.primaryTenant.id, - createdTenantIds, - directMessages: deriveDmThreads(directMail, [manifest.account.email]), - primaryThreads, - }; -} - export type ForkSubThreadParent = { workbenchLocalId: string; tenantId: string; @@ -484,13 +272,6 @@ const HubErrorEnvelope = type({ error: { code: "string", "message?": "string", "userMessage?": "string" }, }); -const WorkflowAssetListShape = type({ id: "string", name: "string" }).array(); -const DeploymentListShape = type({ - definitionAssetId: "string", - status: "string", -}).array(); -const TERMINAL_DEPLOYMENT_STATUSES = new Set(["released", "failed", "destroy_failed"]); - async function readJson(response: Response, operation: string): Promise { if (!response.ok) { const envelope = HubErrorEnvelope(await response.json().catch(() => undefined)); @@ -601,33 +382,6 @@ export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { "deployWorkflow", ); }, - async hasWorkflowDeployment(tenantId, assetName) { - const assets = parseBoundary( - WorkflowAssetListShape, - await readJson( - await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=workflow&inherited=false`, - ), - "listWorkflowAssets", - ), - "listWorkflowAssets", - ); - const asset = assets.find((row) => row.name === assetName); - if (asset === undefined) return false; - const deployments = parseBoundary( - DeploymentListShape, - await readJson( - await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/workflows/deployments`), - "listDeployments", - ), - "listDeployments", - ); - return deployments.some( - (deployment) => - deployment.definitionAssetId === asset.id && - !TERMINAL_DEPLOYMENT_STATUSES.has(deployment.status), - ); - }, async sendRunMail(input) { const { tenantId, ...message } = input; const body = await readJson( diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts index ee965cb2b..e4cbf34e7 100644 --- a/apps/web/src/needs-list.ts +++ b/apps/web/src/needs-list.ts @@ -1,10 +1,6 @@ import { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; import { type } from "arktype"; -const ManifestVersion = type("number").narrow( - (version, ctx) => version === 1 || ctx.mustBe("manifest version 1"), -); - export const WorkflowDeployInputSchema = type({ source: WorkflowDefinitionSource, entry: "string > 0", @@ -14,113 +10,6 @@ export const WorkflowDeployInputSchema = type({ }); export type WorkflowDeployInput = typeof WorkflowDeployInputSchema.infer; -const DesiredPrincipalSchema = type({ - kind: "'user' | 'workflow'", - refId: "string > 0", - "email?": "string > 0", - status: "'active'", - roles: "string[]", -}); - -export const NeedsListSchema = type({ - version: ManifestVersion, - account: { - id: "string > 0", - name: "string > 0", - email: "string > 0", - }, - primaryTenant: { - kind: "'primary'", - want: "'existing'", - }, - worker: { - definitionRefId: "string > 0", - scope: "'top-level'", - want: "'running'", - "deploy?": WorkflowDeployInputSchema, - }, - workbenches: type({ - localId: "string > 0", - slug: "string > 0", - name: "string > 0", - kind: "'workbench'", - parent: "'primary'", - principals: DesiredPrincipalSchema.array(), - "initialMessage?": type({ - runId: "string > 0", - content: "string > 0", - }), - }).array(), -}); -export type NeedsList = typeof NeedsListSchema.infer; - -export type NeedsListInput = { - readonly account: { - readonly id: string; - readonly name: string; - readonly email: string; - }; - readonly workerDefinitionRefId: string; - readonly workerDeploy?: WorkflowDeployInput; - readonly workbenches?: readonly { - readonly localId: string; - readonly slug: string; - readonly name: string; - readonly principals?: readonly { - readonly kind: "user" | "workflow"; - readonly refId: string; - readonly email?: string; - readonly roles: readonly string[]; - }[]; - /** Caller-supplied primary-thread first message for the child tenant: - * the run to address and the exact content to send. Never synthesized. */ - readonly initialMessage?: { - readonly runId: string; - readonly content: string; - }; - }[]; -}; - -export function buildNeedsList(input: NeedsListInput): NeedsList { - return { - version: 1, - account: { ...input.account }, - primaryTenant: { kind: "primary", want: "existing" }, - worker: { - definitionRefId: input.workerDefinitionRefId, - scope: "top-level", - want: "running", - ...(input.workerDeploy === undefined ? {} : { deploy: input.workerDeploy }), - }, - workbenches: (input.workbenches ?? []).map((workbench) => ({ - localId: workbench.localId, - slug: workbench.slug, - name: workbench.name, - kind: "workbench" as const, - parent: "primary" as const, - principals: (workbench.principals ?? []).map((principal) => ({ - kind: principal.kind, - refId: principal.refId, - ...(principal.email === undefined ? {} : { email: principal.email }), - status: "active" as const, - roles: [...principal.roles], - })), - ...(workbench.initialMessage === undefined - ? {} - : { - initialMessage: { - runId: workbench.initialMessage.runId, - content: workbench.initialMessage.content, - }, - }), - })), - }; -} - -export function parseNeedsList(data: unknown) { - return NeedsListSchema(data); -} - export type StringStorage = { getItem(key: string): string | null; setItem(key: string, value: string): void; diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index 2a16c3582..4547b228f 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -1,10 +1,8 @@ -// A read-only probe: setup counts as done only once the primary tenant has -// Worker live, so an install that failed midway resumes instead of landing -// in an empty shell. +// A read-only probe: setup counts as done once the primary tenant exists and +// has a model connected; the first bench's worker is the first worker. import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; -import { WORKER_SOURCE_CONFIG } from "./worker-source"; import { createFetchStockHub, findOwnedTenants, type StockHub } from "./needs-converge"; // The cheap pre-skip read to tell "no credential yet" apart from "still @@ -62,8 +60,11 @@ export async function triggerFirstLoginProvisioning( const owned = await findOwnedTenants(hub); const primary = owned.find((tenant) => tenant.parentId === null); if (primary === undefined) return { kind: "needs-onboarding" }; - const deployed = await hub.hasWorkflowDeployment(primary.id, WORKER_SOURCE_CONFIG.assetName); - return deployed ? { kind: "existing-member" } : { kind: "needs-onboarding" }; + const credential = await hasActiveCredential(primary.id); + if (credential.kind === "error") throw new Error("Checking your model access hit a snag."); + return credential.kind === "active" + ? { kind: "existing-member" } + : { kind: "needs-onboarding" }; } catch (cause) { const refId = reportError(cause, { operation: "first_login_provisioning" }); return { kind: "error", message: FALLBACK_ERROR_MESSAGE, refId }; diff --git a/apps/web/src/onboarding/onboarding-layout.tsx b/apps/web/src/onboarding/onboarding-layout.tsx index 10450c724..c305ccf78 100644 --- a/apps/web/src/onboarding/onboarding-layout.tsx +++ b/apps/web/src/onboarding/onboarding-layout.tsx @@ -1,17 +1,17 @@ import { CorbitsMark } from "@corbits/react-ui"; import type { ReactNode } from "react"; -const STEP_LABELS = ["Workspace", "Model", "Worker"] as const; +const STEP_LABELS = ["Workspace", "Model"] as const; /** * Single centered column with a step progress bar, matching the onboarding - * mockup. `step` is the index of the current step (0-2; 3 = all done). + * mockup. `step` is the index of the current step (0-1; 2 = all done). */ export function OnboardingLayout({ step, children, }: { - readonly step: 0 | 1 | 2 | 3; + readonly step: 0 | 1 | 2; readonly children: ReactNode; }) { return ( diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 4b281e3dc..6ecfb4c61 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,38 +1,25 @@ -// A gap the install loop can't cross renders here with a retry, never a -// silent "ready". +// A gap the setup can't cross renders here with a retry. Setup ends at a +// connected model; the first workbench is created from the new-workbench +// prompt, and its worker is the first worker. import { Button, EmptyState } from "@corbits/react-ui"; import { WarningCircle } from "@/lib/icons"; import { WorkbenchLoadingState } from "@/chat"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -import { ensurePrimaryTenant, runPortableClientBootstrap } from "../client-bootstrap"; +import { ensurePrimaryTenant } from "../client-bootstrap"; import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; -import { deployWorkerSource } from "../worker-deploy"; import { useNavigate } from "../navigation"; import { NEW_WORKBENCH_PATH } from "../routes"; import { triggerFirstLoginProvisioning } from "../onboarding"; import { setProviderSkipped } from "../provider-skip"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; -import { - ProviderConnectStep, - resolveExistingOffering, - type ExistingOffering, -} from "../onboarding/provider-connect-step"; -import type { WorkflowDeployInput } from "../needs-list"; +import { ProviderConnectStep, resolveExistingOffering } from "../onboarding/provider-connect-step"; import type { SessionUser } from "../session"; type GateState = | { readonly phase: "checking" } | { readonly phase: "resolving-tenant" } | { readonly phase: "provider-setup"; readonly tenantId: string } - | { readonly phase: "publishing-worker" } - | { readonly phase: "installing"; readonly workerDeploy: WorkflowDeployInput } - | { readonly phase: "ready" } - | { - readonly phase: "setup-pending"; - readonly message: string; - readonly refId?: string; - } | { readonly phase: "error"; readonly message: string; @@ -42,7 +29,6 @@ type GateState = export function OnboardingPage({ user }: { readonly user: SessionUser }) { const navigate = useNavigate(); const [state, setState] = useState({ phase: "checking" }); - const installRef = useRef | null>(null); // Remembered so a reload lands in the shell instead of back here. function skipForNow() { @@ -50,9 +36,9 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { navigate(NEW_WORKBENCH_PATH); } - // One status read per landing (plus each manual recheck): a hub that - // already has tenants means setup is done; an empty hub starts the - // converge loop immediately rather than showing a dead-end panel. + // One status read per landing (plus each manual recheck): a workspace + // with a connected model means setup is done; otherwise the workspace is + // ensured and the provider step starts. const checkStatus = useCallback(() => { setState({ phase: "checking" }); void triggerFirstLoginProvisioning().then((result) => { @@ -77,8 +63,8 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { checkStatus(); }, [checkStatus]); - // A separate phase from "installing" because a credential connect - // needs a tenant id to write against. + // A separate phase because a credential connect needs a tenant id to + // write against. useEffect(() => { if (state.phase !== "resolving-tenant") return; let cancelled = false; @@ -100,8 +86,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { (existing) => { if (cancelled) return; if (existing !== null) { - setState({ phase: "publishing-worker" }); - void publishAndInstall(primary.id, primary.domain, existing); + navigate(NEW_WORKBENCH_PATH); return; } setState({ phase: "provider-setup", tenantId: primary.id }); @@ -127,70 +112,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { return () => { cancelled = true; }; - // `publishAndInstall` is defined below and stable across renders (it - // closes over nothing but `setState`), so it is intentionally left - // out of this effect's dependency list. - }, [state.phase, user]); - - // Shared by both the already-resolved-offering path and the - // operator-connected path. - function publishAndInstall( - tenantId: string, - tenantDomain: string, - offering: ExistingOffering, - ): Promise { - return deployWorkerSource({ - tenantId, - tenantDomain, - sourceOfferingIds: offering.sourceOfferingIds, - defaultSourceOfferingId: offering.defaultSourceOfferingId, - declaredSources: offering.declaredSources, - }).then( - (workerDeploy) => setState({ phase: "installing", workerDeploy }), - (error: unknown) => { - setState({ - phase: "error", - message: - error instanceof Error ? error.message : "Publishing your worker's source hit a snag.", - }); - }, - ); - } - - // Step 4: the installer itself, run once a `workerDeploy` is in hand. A - // converged install hands off to the shell; a stock capability gap or - // a hard failure surfaces here instead of retrying silently forever. - useEffect(() => { - if (state.phase !== "installing") return; - let cancelled = false; - // StrictMode re-runs this effect once; the converge deploys Worker, so a - // second concurrent run would deploy her twice. Reuse the in-flight one. - installRef.current ??= runPortableClientBootstrap(user, { - workerDeploy: state.workerDeploy, - }); - void installRef.current.then( - (result) => { - if (cancelled) return; - if (result.kind === "ready") { - setState({ phase: "ready" }); - } else if (result.code === "stock-capability-missing") { - setState({ phase: "setup-pending", message: result.gap }); - } else { - setState({ phase: "error", message: result.message }); - } - }, - (error: unknown) => { - if (cancelled) return; - setState({ - phase: "error", - message: error instanceof Error ? error.message : "Setting up your workbench hit a snag.", - }); - }, - ); - return () => { - cancelled = true; - }; - }, [state, user, navigate]); + }, [state.phase, user, navigate]); if (state.phase === "checking" || state.phase === "resolving-tenant") { return ( @@ -218,24 +140,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
{ - const tenantId = state.tenantId; - setState({ phase: "publishing-worker" }); - // The tenant's domain never changes mid-flow — re-derive - // it fresh rather than threading it through state, since - // `resolving-tenant` already looked the tenant up once. - void findOwnedTenants(createFetchStockHub()).then((owned) => { - const primary = owned.find((t) => t.id === tenantId); - if (primary === undefined) { - setState({ - phase: "error", - message: "Your primary workbench disappeared mid-setup — please retry.", - }); - return; - } - void publishAndInstall(tenantId, primary.domain, offering); - }); - }} + onConnected={() => navigate(NEW_WORKBENCH_PATH)} onError={(message) => setState({ phase: "error", message })} onSkip={skipForNow} /> @@ -245,64 +150,6 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { ); } - if (state.phase === "publishing-worker" || state.phase === "installing") { - return ( - -
- -
-
- ); - } - - if (state.phase === "ready") { - return ( - -
-

Your worker is ready

-

- Describe the job and your co-worker sets up the rest. -

-
-
- -
-
-
-
- ); - } - - if (state.phase === "setup-pending") { - return ( - -
-

Set up your workbench

-

{state.message}

-
-
- - -
-
-
-
- ); - } - return (
diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index b4af749cd..caf7909c8 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -8,7 +8,6 @@ import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore import { getLogger } from "@/lib/client-log"; import { App } from "./app"; import { validatedNextPath } from "./login-next"; -import { finishDeferredWorkerSetup } from "./deferred-worker-setup"; import { triggerFirstLoginProvisioning } from "./onboarding"; import { isProviderSkipped } from "./provider-skip"; import { getPath, navigateTo, subscribeToPath } from "./router-store"; @@ -54,11 +53,8 @@ export function Root() { void triggerFirstLoginProvisioning().then((result) => { if (cancelled) return; if (result.kind === "needs-onboarding") { - // A skipped provider step keeps the shell; Worker deploys once a model exists. - if (isProviderSkipped(provisionedUser.id)) { - void finishDeferredWorkerSetup(provisionedUser); - return; - } + // A skipped provider step keeps the shell. + if (isProviderSkipped(provisionedUser.id)) return; navigate(ONBOARDING_PATH); } else if (result.kind === "error") { setProvisioningError({ message: result.message, refId: result.refId }); diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index 2050ff454..c32dd4355 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -32,9 +32,9 @@ import { type ModelResponse, } from "@/settings/inference"; import { redeployWorkerForModelChange } from "@/settings/worker-model-redeploy"; +import { isDefaultWorker, listChatAgents } from "@/chat/threads-api"; +import { listWorkbenchTenants } from "@/chat/workbench-tenants"; import { tenantKeys } from "@/query-client"; -import { finishDeferredWorkerSetup } from "@/deferred-worker-setup"; -import { useSessionUser } from "@/navigation"; import { deleteCredential, listCredentials, @@ -91,7 +91,6 @@ type CredentialsData = { export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) { const queryClient = useQueryClient(); - const user = useSessionUser(); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [loginId, setLoginId] = useState(null); @@ -135,8 +134,6 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n function reload() { if (tenantId === null) return; - // A provider connected after a skipped onboarding finishes Worker's deploy. - if (user !== undefined) void finishDeferredWorkerSetup(user); void queryClient.invalidateQueries({ queryKey: tenantKeys.credentials(tenantId), }); @@ -217,17 +214,22 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n input.model, ); if (newOffering.id !== offering.id) { - // Worker's own deployed run pins the old offering id — moving her onto + // Each bench's worker run pins the old offering id — moving it onto // the new one first, then retiring the old one, is the only order // that never leaves a live run pointed at a dead offering. A failed - // redeploy throws here and both offerings are left in place. - await redeployWorkerForModelChange({ - tenantId, - oldOfferingId: offering.id, - newOfferingId: newOffering.id, - provider: provider.plugin, - newCanonicalName: input.model, - }); + // redeploy throws here and both offerings are left in place. The + // workspace itself runs no worker. + for (const bench of await listWorkbenchTenants(tenantId)) { + const worker = (await listChatAgents(bench.id)).find(isDefaultWorker); + if (worker === undefined) continue; + await redeployWorkerForModelChange({ + tenantId: bench.id, + oldOfferingId: offering.id, + newOfferingId: newOffering.id, + provider: provider.plugin, + newCanonicalName: input.model, + }); + } await deleteOwnOffering(tenantId, offering.id); } } diff --git a/apps/web/src/tools/mcp-servers-query.ts b/apps/web/src/tools/mcp-servers-query.ts index d961d5752..52f3527fa 100644 --- a/apps/web/src/tools/mcp-servers-query.ts +++ b/apps/web/src/tools/mcp-servers-query.ts @@ -147,10 +147,7 @@ async function redeployWorkspaceWorkers( ): Promise { const workbenches = await listWorkbenchTenants(workspaceTenantId); const result = await redeployWorkerTenants( - [ - { id: workspaceTenantId, name: "workspace" }, - ...workbenches.map((workbench) => ({ id: workbench.id, name: workbench.title })), - ], + workbenches.map((workbench) => ({ id: workbench.id, name: workbench.title })), { findWorker: async (tenantId) => (await listChatAgents(tenantId)).find(isDefaultWorker), redeploy: (tenantId, worker) => redeployWorkbenchAgent(tenantId, worker), diff --git a/apps/web/src/worker-deploy.ts b/apps/web/src/worker-deploy.ts index 13c9b3306..d7917ee2b 100644 --- a/apps/web/src/worker-deploy.ts +++ b/apps/web/src/worker-deploy.ts @@ -243,7 +243,7 @@ export async function pushWorkerSource( /** The pure mapping this module exists to get right: the operator's * offering pick plus the commit just pushed, turned into the exact - * `WorkflowDeployInput` `convergeNeedsList` needs. */ + * `WorkflowDeployInput` the stock deploy route needs. */ export function buildWorkerDeployInput(args: { assetId: string; commitSha: string; @@ -271,7 +271,7 @@ export function buildWorkerDeployInput(args: { } /** Orchestrates the steps above and returns the `workerDeploy` input - * ready to hand to `bootstrapClientSession`/`convergeNeedsList`. */ + * ready to hand to the stock deploy route. */ export async function deployWorkerSource( args: { tenantId: string; diff --git a/docs/chat-mail-threading.md b/docs/chat-mail-threading.md index 73e15d0a4..6d47bc1ea 100644 --- a/docs/chat-mail-threading.md +++ b/docs/chat-mail-threading.md @@ -34,14 +34,6 @@ matches the local part case-insensitively, so both forms reach the same inbox: threads written before this keep their mixed-case addresses, new mail carries the lowercase one, and nothing is migrated. -## Primary-thread root resolution - -`readHubSnapshot`'s caller resolves a workbench's primary-thread root from -the recorded `primaryThreadMessageId`, falling back to `mail[0]` only for -mail sent before that id existed. The mailbox list is assumed oldest-first; -if the hub ever returns newest-first or unordered rows the fallback -mistargets, so the recorded id stays authoritative. - ## Sub-thread fork replay `forkSubThread` treats a fork as already sent when parent + subject + diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index c2ec564a5..62c2d895c 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -37,7 +37,6 @@ async function signUp(page: Page, origin: string): Promise { await page.waitForSelector("input[type=password]"); await page.type("input[type=password]", "sk-ant-placeholder"); await clickText(page, "button", "Connect"); - await clickText(page, "button", "Start your first workbench"); } async function createBench(page: Page, message: string): Promise { @@ -72,7 +71,13 @@ async function runFlow(page: Page, origin: string): Promise { const children = tenants.filter((t) => t.parentId === workspace?.id); expect(children.map((t) => t.id).sort()).toEqual([...ids].sort()); - // (b) each bench deploys exactly one agent (one definition asset). + // (b) the workspace runs no worker; each bench deploys exactly one agent + // (one definition asset). + const workspaceDeployments = await hubJson( + page, + `/api/tenants/${workspace?.id}/workflows/deployments`, + ); + expect(workspaceDeployments).toHaveLength(0); for (const id of ids) { const deployments = await hubJson<{ definitionAssetId: string }[]>( page, @@ -81,16 +86,15 @@ async function runFlow(page: Page, origin: string): Promise { expect(new Set(deployments.map((d) => d.definitionAssetId)).size).toBe(1); } - // (c) sidebar: three bench rows with short titles, plus the worker group. + // (c) sidebar: three bench rows with short titles. await page.goto(`${origin}/w/${ids[0]}`, { waitUntil: "networkidle0" }); - await waitForText(page, "Workers"); + await waitForText(page, "Workbenches"); const rows = (await page.evaluate( `Array.from(document.querySelectorAll(".panel-stack-group")).map((g) => ({ label: g.querySelector(".shell-panel-section-label")?.textContent, names: Array.from(g.querySelectorAll(".shell-ch-name")).map((n) => n.textContent) }))`, )) as { label: string; names: string[] }[]; const benches = rows.find((g) => g.label === "Workbenches")?.names ?? []; expect(benches).toHaveLength(3); for (const name of benches) expect(name.length).toBeLessThanOrEqual(40); - expect(rows.find((g) => g.label === "Workers")?.names.length).toBeGreaterThanOrEqual(1); // (d) no bench shows another's opening message. for (const [i, id] of ids.entries()) { diff --git a/e2e/browser/reply.test.ts b/e2e/browser/reply.test.ts index d5a544327..054b8b462 100644 --- a/e2e/browser/reply.test.ts +++ b/e2e/browser/reply.test.ts @@ -34,7 +34,6 @@ describeBrowser("worker reply", () => { await model[1]?.type("mock-model"); await clickText(page, "button", "Connect"); - await clickText(page, "button", "Start your first workbench"); await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); await page.type("textarea", "Say hello"); await page.click("button[aria-label='Start this workbench']"); diff --git a/e2e/browser/setup.test.ts b/e2e/browser/setup.test.ts index 6baa3c28f..dea91697e 100644 --- a/e2e/browser/setup.test.ts +++ b/e2e/browser/setup.test.ts @@ -1,6 +1,7 @@ // The hub seeds nothing: before signup it has no tenants for the visitor, -// and after the client's setup the workspace and Worker exist, created by the -// signed-in principal. Reloading the app never duplicates either. +// and after the client's setup the workspace exists with no deployment, +// created by the signed-in principal; the first bench brings the first +// worker. Reloading the app never duplicates either. import { expect, test } from "bun:test"; import type { Page } from "puppeteer-core"; import { bootBrowserApp, browserGate } from "../lib/browser"; @@ -75,22 +76,35 @@ describeBrowser("client setup", () => { await page.waitForSelector("input[type=password]"); await page.type("input[type=password]", "sk-ant-placeholder"); await clickText(page, "button", "Connect"); - await clickText(page, "button", "Start your first workbench"); await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - const first = await snapshot(page); - expect(first.tenants).toHaveLength(1); - expect(first.tenants[0]?.["parentId"] ?? null).toBeNull(); - expect(first.deployments).toHaveLength(1); - expect(first.deployments[0]?.["tenantId"]).toBe(first.tenants[0]?.["id"]); + // Setup leaves one workspace tenant and no deployment anywhere. + const setup = await snapshot(page); + expect(setup.tenants).toHaveLength(1); + expect(setup.tenants[0]?.["parentId"] ?? null).toBeNull(); + expect(setup.deployments).toHaveLength(0); // The stock tenant carries no creator field; the signed-in user's own // principal holding owner on it is the client-created proof. const mine = items((await probe(page, "/api/me/principals")).body); expect(mine).toHaveLength(1); - expect(mine[0]?.["tenantId"]).toBe(first.tenants[0]?.["id"]); + expect(mine[0]?.["tenantId"]).toBe(setup.tenants[0]?.["id"]); expect(mine[0]?.["kind"]).toBe("user"); expect(JSON.stringify(mine[0]?.["roles"])).toContain("owner"); + // The first bench carries the first worker. + await page.type("textarea", "Summarize what shipped this week"); + await page.click("button[aria-label='Start this workbench']"); + await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); + const benchId = new URL(page.url()).pathname.slice("/w/".length); + + const first = await snapshot(page); + expect(first.tenants).toHaveLength(2); + expect(first.tenants.find((t) => t["id"] === benchId)?.["parentId"]).toBe( + setup.tenants[0]?.["id"], + ); + expect(first.deployments).toHaveLength(1); + expect(first.deployments[0]?.["tenantId"]).toBe(benchId); + for (let i = 0; i < 2; i++) { await page.reload({ waitUntil: "networkidle0" }); // Give the client's setup time to (wrongly) run again. diff --git a/e2e/browser/zero-to-one.test.ts b/e2e/browser/zero-to-one.test.ts index 8bf7e7373..59bbe236b 100644 --- a/e2e/browser/zero-to-one.test.ts +++ b/e2e/browser/zero-to-one.test.ts @@ -98,7 +98,6 @@ describeBrowser("0-to-1 gate", () => { await fields[1]?.type("mock-model"); await clickText(page, "button", "Connect"); - await clickText(page, "button", "Start your first workbench"); await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); await page.type("textarea", "Say hello"); await page.click("button[aria-label='Start this workbench']"); diff --git a/e2e/lib/first-run.ts b/e2e/lib/first-run.ts index 1ab5a5e01..820ef952e 100644 --- a/e2e/lib/first-run.ts +++ b/e2e/lib/first-run.ts @@ -36,9 +36,8 @@ export async function runFirstRunFlow(page: Page, origin: string): Promise await page.type("input[type=password]", "sk-ant-placeholder"); await clickText(page, "button", "Connect"); - // Connecting installs Worker, then the ready screen hands off to the - // new-workbench prompt (a workspace with no workbenches). - await clickText(page, "button", "Start your first workbench"); + // Connecting hands off to the new-workbench prompt (a workspace with no + // workbenches); the first bench's worker is the first worker. await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); await page.type("textarea", FIRST_WORKBENCH); await page.click("button[aria-label='Start this workbench']");