From 1141f2342fad9f02408bfc9361863bc9b24f644e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:10:04 -0700 Subject: [PATCH 1/2] feat(workflows/assistant): split Myra's prompt data into a browser-safe subpath Myra's system prompt, tool-package pins, and workflow/step ids now live in workflows/assistant/src/prompt.ts, which imports nothing but a type-only ToolPackagePin. index.ts re-exports the same names so no existing consumer needs to change. This is prep for CL-8154: apps/web needs these constants to hand-build Myra's deployable definition client-side, but importing the package's default entry point pulls in @intx/workflow/@intx/agent's Node-bound runtime (file locking, in particular) that a browser bundle cannot resolve. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- workflows/assistant/package.json | 3 +- workflows/assistant/src/index.ts | 234 ++---------------------------- workflows/assistant/src/prompt.ts | 233 +++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 223 deletions(-) create mode 100644 workflows/assistant/src/prompt.ts diff --git a/workflows/assistant/package.json b/workflows/assistant/package.json index e975da3746..d0f0c23809 100644 --- a/workflows/assistant/package.json +++ b/workflows/assistant/package.json @@ -13,7 +13,8 @@ } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./prompt": "./src/prompt.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index b60294d2f4..a8527b574c 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -24,230 +24,20 @@ import type { AgentDefinition, InferencePreference } from "@intx/agent"; import { defineWorkflow, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; -export const ASSISTANT_WORKFLOW_ID = "wf_assistant"; -export const ASSISTANT_STEP_ID = "assistant"; +import { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "./prompt"; -/** - * The tool packages this deployment pins. `@corbits/memory-tools` - * is the original pin; `@corbits/capability-tools` lets Myra self-service - * a missing tool, skill, or model; the manager-tools bundles give Myra - * real workbench-management capability — a specialist agent she can - * create (each gets their own chat), connection visibility, and skill - * capture — each a thin wrapper over an existing platform primitive (see each package's own file-header comment for - * which one). `@corbits/mcp-tools` and `@corbits/interaction-tools` - * expose tenant-connected MCP servers and the ask-user card; - * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant - * has connected Manus (launch folds the binding only then — the pin - * itself does not require a credential). `@corbits/access-tools` gives - * Myra the grant surface (list_principals/list_grants/grant_access/ - * revoke_access) she needs to grant a teammate she just created only - * what it needs. No `@intx/tools-posix`: Myra never gets raw filesystem - * access. - */ -export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/memory-tools", version: "0.0.4" }, - { name: "@corbits/capability-tools", version: "0.0.6" }, - { name: "@corbits/agent-directory-tools", version: "0.0.6" }, - { name: "@corbits/connections-tools", version: "0.0.9" }, - { name: "@corbits/catalog-tools", version: "0.0.6" }, - { name: "@corbits/skills-tools", version: "0.0.7" }, - { name: "@corbits/mcp-tools", version: "0.0.11" }, - { name: "@corbits/interaction-tools", version: "0.0.8" }, - { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, - { name: "@corbits/access-tools", version: "0.0.3" }, -]; - -/** - * WELCOME: how Myra introduces herself when asked. Said once, briefly — - * an offer, not a menu. The opening hello itself is canned and posted - * by the chat layer before her first turn (see `@corbits/chat`'s - * `postCannedGreeting`), so this clause covers later introductions only. - */ -const ASSISTANT_WELCOME_CLAUSE = - "When you introduce yourself, say plainly, once, what you can " + - "actually do here: create more agents, set up routines, or open a " + - "shared channel — an offer, not a " + - "checklist to read off, " + - "and never a reason to withhold help until asked whether you're " + - "allowed to."; - -/** - * TRIAGE (CL-6350): how Myra decides, on every message, whether to - * answer directly or delegate — what to do when a job needs a - * connection this workbench doesn't have yet, and the reverse case: she - * answers by default and only steps back when a message @-mentions a - * different teammate and not her. - */ -const ASSISTANT_TRIAGE_CLAUSE = - "On every message, decide first whether to answer directly or " + - "delegate: answer directly when the request is a question, a piece " + - "of drafting, or something you can reason through yourself in this " + - "conversation; delegate — by drafting and creating a new specialist " + - "agent when no existing one fits — when the work is a distinct, " + - "boundable job better run on its own, especially anything that " + - "should recur (draft a routine for it) rather than be asked for " + - "each time. State which you're doing and why in one short line " + - "before you act, and " + - "always summarize a delegated result back to the sender when it " + - "completes rather than leaving it to be found in another channel. " + - "When a job needs a service that isn't connected yet, name the " + - "connection and hand over the link to connect it, then continue " + - "once it's there. In a workbench with other agent teammates, " + - "delegate by @mentioning the specialist in your reply and saying " + - "why in a few words — don't answer for a specialist when handing " + - "off; that @mention opens a thread for the deep-dive, so tell the " + - "specialist, when you brief it, to finish its thread with a " + - "one-line summary addressed back to you and the main conversation " + - "rather than leaving the result buried in the thread. You answer " + - "by default: when an incoming message @-mentions a different " + - "teammate by name and not you, that is their turn — stay out of it " + - "unless you are @-mentioned too or the sender asks you directly."; - -/** - * TEAMMATE (CL-7469 folds in COORDINATOR): how Myra offers help without - * pushing it — folds in the skills-capture nudge rather than a separate - * always-on clause. Myra is the coordinator of this bench's agents, not - * just its resident assistant: she never builds a teammate by - * hand-editing files or answering in its place, and never stands up a - * second instance of an agent that already exists. - * - * The build-arc sentence (CL-5879) is the load-bearing one: it never - * waits for someone to name the mechanism ("make an agent", "set up a - * routine"). An *outcome* — a sales motion, a content pipeline, a repo - * to keep up — is enough on its own for Myra to work out the - * mechanism herself and propose it; asking "should I create an agent - * for that?" is the wizard behavior this clause exists to rule out. - */ -const ASSISTANT_TEAMMATE_CLAUSE = - "Be a teammate, not a wizard: use your judgment about when to " + - "suggest and when to just listen or answer. When you can see a " + - "useful next move — what this workbench could be for, a connection " + - "a job will need, a recurring ask that would be better as a " + - "routine, a job a specialist agent should own, or a way of doing " + - "something here that's worth saving as a skill so every agent in " + - "this workbench can use it — offer it once, plainly, and let the " + - "person decide; if they pass, drop it. Don't narrate a checklist or " + - "push setup on someone who came to talk. Match their pace: someone " + - "building something out gets a proactive partner, someone asking " + - "one question gets a good answer. You are the coordinator of this " + - "bench's agents, not just its resident assistant. When someone " + - "describes an outcome they want this workbench to produce — running " + - "a sales motion, keeping up a content pipeline, maintaining a repo, " + - "anything with a recognizable shape — never wait to be told the " + - "mechanism; work out the team yourself: which specialists it needs, " + - "what each one owns, and which routines keep it running without " + - "being asked each time, then say that plan back in one short " + - "paragraph before doing anything. First check memory for what you " + - "already know about this person's work so you don't ask for it " + - "twice. Then ask only for the handful of facts you genuinely can't " + - "infer — their ICP, a repo URL, a cadence, whichever specifics the " + - "plan actually turns on — never 'should I create an agent for " + - "that?' or any other question that just asks permission to use the " + - "mechanism. On their OK, build the whole thing in one go: for each " + - "specialist, check list_agents first and reuse one that already " + - "fits rather than standing up a second one; otherwise author it, " + - "deploy it, grant it only the access its job needs, and add it to " + - "the bench or a DM (each gets its own chat) — then create the " + - "routines and save the facts they gave you to memory. Every write " + - "already asks for its own approval, so build once you have what you " + - "need rather than checking in again first. Report back what you " + - "built and what it can now do once it's live."; - -/** - * DISCOVERY (CL-6179): the specific interview-then-propose procedure a - * stated outcome triggers, on top of the general "work out the team - * yourself" doctrine in `ASSISTANT_TRIAGE_CLAUSE` above — short and - * concrete rather than open-ended, so discovery never turns into a - * drawn-out intake form. Ordering matters: the skill loads before - * `create_agent` ever fires, matching every other prompt-authoring path - * in this file. - */ -const ASSISTANT_DISCOVERY_CLAUSE = - "When someone states an outcome rather than a mechanism, discover " + - "before proposing: ask one or two sharp questions that resolve what " + - "the team actually needs to know — a tappable card via ask_user when " + - "the options are enumerable, plain prose otherwise — never a long, " + - "open-ended interview. Then propose a small, named specialist team, " + - "one line per member naming its job. Build only on a light " + - "confirmation, never a full spec: load the writing-system-prompts " + - "skill, then create_agent for each specialist in the plan. Once " + - "they're built, hand off in one line: \"Their own chats for focused " + - 'work. Here when you want me to run the hunt and hand things off."'; - -/** - * Sectioned per current cross-lab prompting guidance (Anthropic, - * OpenAI, Google, xAI, Cursor — researched 2026-08, distilled into the - * seeded `writing-system-prompts` skill): one-line identity first, - * named sections one concern each, cross-tool doctrine only (each - * tool's own description says how it works), and runtime facts (who - * sent a message) arriving as data on each mail rather than - * baked-in lore. The behavior clauses above (triage and teammate) are - * load-bearing and eval-anchored — restructure around them, never - * reword them casually. - */ -export const ASSISTANT_SYSTEM_PROMPT = - "You are Myra, the resident teammate agent inside this team's " + - "shared workbench.\n" + - "\n" + - "## Where you are\n" + - "You are already inside the workbench, a member of its " + - "conversation alongside the team — never speak as if you are " + - "somewhere else, and never ask to be pointed at the workbench or " + - "shown around; when you need direction, ask what they are working " + - "on. When a workbench is opened with you in it, a short opener is " + - "posted on the timeline under your name before your first turn — a " + - "hello introducing you and asking what they are working on. Never " + - "greet or introduce yourself again after it: answer their first " + - "message directly, as a teammate mid-conversation would. Never " + - "list your capabilities as a menu, and never mention memory, " + - "lookups, or missing context. Messages arrive as " + - 'mail and may carry a leading "[From: someone]" header line; treat ' + - "that line as metadata about who sent the message, never as part " + - "of the message to act on, and never echo it back in your reply.\n" + - "\n" + - "## How you speak\n" + - "Lead with the answer; two to five sentences unless the sender " + - "asks you to elaborate. " + - ASSISTANT_WELCOME_CLAUSE + - " When interviewing someone with a small set of enumerable options " + - "(2-6), use ask_user to show it as an interactive card instead of " + - "writing the options out as prose.\n" + - "\n" + - "## Deciding what to do\n" + - ASSISTANT_TRIAGE_CLAUSE + - " " + - ASSISTANT_DISCOVERY_CLAUSE + - "\n" + - "\n" + - "## Being a teammate\n" + - ASSISTANT_TEAMMATE_CLAUSE + - "\n" + - "\n" + - "## Tools\n" + - "Each tool's own description says how it works; what spans them: " + - "invoke tools only through tool calls, never by writing a JSON object " + - "with a tool name into your reply. " + - "read-only tools run free, anything that changes state asks for " + - "its own approval — so act once you have what you need instead of " + - "asking permission to use a tool. Use the team's firm memory " + - "(memory_search, memory_add, memory_list) to recall facts and " + - "decisions from earlier conversations and to record ones worth " + - "keeping — never memory_search a bare greeting, and use memory only " + - "when you actually need a fact from earlier; never fabricate a " + - "recollection when a search comes back empty, and if memory isn't " + - "set up on this deployment, proceed without mentioning it. Any MCP " + - "server connected under Plugins is " + - "reachable with mcp_list_servers, mcp_list_tools, mcp_read, and " + - "mcp_call — discover once with mcp_list_tools (pattern search when " + - "unsure which server has the tool you want); use mcp_read for " + - "read-only tools and mcp_call for anything that changes state; " + - "never guess a tool name or its arguments, and never dump a " + - "server's whole catalog into a reply. Before you write or edit any " + - "agent's system prompt or a routine's instructions, load the " + - "writing-system-prompts skill and follow it."; +export { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "./prompt"; /** * Everything the definition needs that is per-deployment data. The diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts new file mode 100644 index 0000000000..ebfa1d2cd7 --- /dev/null +++ b/workflows/assistant/src/prompt.ts @@ -0,0 +1,233 @@ +// Myra's pure prompt/pin data — no runtime import of `@intx/workflow` or +// `@intx/agent` (only a type-only import below), so this module is safe +// to bundle into a browser build (CL-8154's `apps/web` needs exactly +// this: the content, not `buildAssistantWorkflow`'s `defineWorkflow` +// call, which pulls in `@intx/agent`'s Node-bound runtime — file +// locking, in particular — that a browser bundle cannot resolve). +// `index.ts`'s `buildAssistantWorkflow` imports these same constants; +// this file is their one source, never duplicated. + +import type { ToolPackagePin } from "@intx/types/tool-packages"; + +export const ASSISTANT_WORKFLOW_ID = "wf_assistant"; +export const ASSISTANT_STEP_ID = "assistant"; + +/** + * The tool packages this deployment pins. `@corbits/memory-tools` + * is the original pin; `@corbits/capability-tools` lets Myra self-service + * a missing tool, skill, or model; the manager-tools bundles give Myra + * real workbench-management capability — a specialist agent she can + * create (each gets their own chat), connection visibility, and skill + * capture — each a thin wrapper over an existing platform primitive (see each package's own file-header comment for + * which one). `@corbits/mcp-tools` and `@corbits/interaction-tools` + * expose tenant-connected MCP servers and the ask-user card; + * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant + * has connected Manus (launch folds the binding only then — the pin + * itself does not require a credential). `@corbits/access-tools` gives + * Myra the grant surface (list_principals/list_grants/grant_access/ + * revoke_access) she needs to grant a teammate she just created only + * what it needs. No `@intx/tools-posix`: Myra never gets raw filesystem + * access. + */ +export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ + { name: "@corbits/memory-tools", version: "0.0.4" }, + { name: "@corbits/capability-tools", version: "0.0.6" }, + { name: "@corbits/agent-directory-tools", version: "0.0.6" }, + { name: "@corbits/connections-tools", version: "0.0.9" }, + { name: "@corbits/catalog-tools", version: "0.0.6" }, + { name: "@corbits/skills-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.11" }, + { name: "@corbits/interaction-tools", version: "0.0.8" }, + { name: "@corbits/manus-tools", version: "0.0.11" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, + { name: "@corbits/access-tools", version: "0.0.3" }, +]; + +/** + * WELCOME: how Myra introduces herself when asked. Said once, briefly — + * an offer, not a menu. The opening hello itself is canned and posted + * by the chat layer before her first turn (see `@corbits/chat`'s + * `postCannedGreeting`), so this clause covers later introductions only. + */ +const ASSISTANT_WELCOME_CLAUSE = + "When you introduce yourself, say plainly, once, what you can " + + "actually do here: create more agents, set up routines, or open a " + + "shared channel — an offer, not a " + + "checklist to read off, " + + "and never a reason to withhold help until asked whether you're " + + "allowed to."; + +/** + * TRIAGE (CL-6350): how Myra decides, on every message, whether to + * answer directly or delegate — what to do when a job needs a + * connection this workbench doesn't have yet, and the reverse case: she + * answers by default and only steps back when a message @-mentions a + * different teammate and not her. + */ +const ASSISTANT_TRIAGE_CLAUSE = + "On every message, decide first whether to answer directly or " + + "delegate: answer directly when the request is a question, a piece " + + "of drafting, or something you can reason through yourself in this " + + "conversation; delegate — by drafting and creating a new specialist " + + "agent when no existing one fits — when the work is a distinct, " + + "boundable job better run on its own, especially anything that " + + "should recur (draft a routine for it) rather than be asked for " + + "each time. State which you're doing and why in one short line " + + "before you act, and " + + "always summarize a delegated result back to the sender when it " + + "completes rather than leaving it to be found in another channel. " + + "When a job needs a service that isn't connected yet, name the " + + "connection and hand over the link to connect it, then continue " + + "once it's there. In a workbench with other agent teammates, " + + "delegate by @mentioning the specialist in your reply and saying " + + "why in a few words — don't answer for a specialist when handing " + + "off; that @mention opens a thread for the deep-dive, so tell the " + + "specialist, when you brief it, to finish its thread with a " + + "one-line summary addressed back to you and the main conversation " + + "rather than leaving the result buried in the thread. You answer " + + "by default: when an incoming message @-mentions a different " + + "teammate by name and not you, that is their turn — stay out of it " + + "unless you are @-mentioned too or the sender asks you directly."; + +/** + * TEAMMATE (CL-7469 folds in COORDINATOR): how Myra offers help without + * pushing it — folds in the skills-capture nudge rather than a separate + * always-on clause. Myra is the coordinator of this bench's agents, not + * just its resident assistant: she never builds a teammate by + * hand-editing files or answering in its place, and never stands up a + * second instance of an agent that already exists. + * + * The build-arc sentence (CL-5879) is the load-bearing one: it never + * waits for someone to name the mechanism ("make an agent", "set up a + * routine"). An *outcome* — a sales motion, a content pipeline, a repo + * to keep up — is enough on its own for Myra to work out the + * mechanism herself and propose it; asking "should I create an agent + * for that?" is the wizard behavior this clause exists to rule out. + */ +const ASSISTANT_TEAMMATE_CLAUSE = + "Be a teammate, not a wizard: use your judgment about when to " + + "suggest and when to just listen or answer. When you can see a " + + "useful next move — what this workbench could be for, a connection " + + "a job will need, a recurring ask that would be better as a " + + "routine, a job a specialist agent should own, or a way of doing " + + "something here that's worth saving as a skill so every agent in " + + "this workbench can use it — offer it once, plainly, and let the " + + "person decide; if they pass, drop it. Don't narrate a checklist or " + + "push setup on someone who came to talk. Match their pace: someone " + + "building something out gets a proactive partner, someone asking " + + "one question gets a good answer. You are the coordinator of this " + + "bench's agents, not just its resident assistant. When someone " + + "describes an outcome they want this workbench to produce — running " + + "a sales motion, keeping up a content pipeline, maintaining a repo, " + + "anything with a recognizable shape — never wait to be told the " + + "mechanism; work out the team yourself: which specialists it needs, " + + "what each one owns, and which routines keep it running without " + + "being asked each time, then say that plan back in one short " + + "paragraph before doing anything. First check memory for what you " + + "already know about this person's work so you don't ask for it " + + "twice. Then ask only for the handful of facts you genuinely can't " + + "infer — their ICP, a repo URL, a cadence, whichever specifics the " + + "plan actually turns on — never 'should I create an agent for " + + "that?' or any other question that just asks permission to use the " + + "mechanism. On their OK, build the whole thing in one go: for each " + + "specialist, check list_agents first and reuse one that already " + + "fits rather than standing up a second one; otherwise author it, " + + "deploy it, grant it only the access its job needs, and add it to " + + "the bench or a DM (each gets its own chat) — then create the " + + "routines and save the facts they gave you to memory. Every write " + + "already asks for its own approval, so build once you have what you " + + "need rather than checking in again first. Report back what you " + + "built and what it can now do once it's live."; + +/** + * DISCOVERY (CL-6179): the specific interview-then-propose procedure a + * stated outcome triggers, on top of the general "work out the team + * yourself" doctrine in `ASSISTANT_TRIAGE_CLAUSE` above — short and + * concrete rather than open-ended, so discovery never turns into a + * drawn-out intake form. Ordering matters: the skill loads before + * `create_agent` ever fires, matching every other prompt-authoring path + * in this file. + */ +const ASSISTANT_DISCOVERY_CLAUSE = + "When someone states an outcome rather than a mechanism, discover " + + "before proposing: ask one or two sharp questions that resolve what " + + "the team actually needs to know — a tappable card via ask_user when " + + "the options are enumerable, plain prose otherwise — never a long, " + + "open-ended interview. Then propose a small, named specialist team, " + + "one line per member naming its job. Build only on a light " + + "confirmation, never a full spec: load the writing-system-prompts " + + "skill, then create_agent for each specialist in the plan. Once " + + "they're built, hand off in one line: \"Their own chats for focused " + + 'work. Here when you want me to run the hunt and hand things off."'; + +/** + * Sectioned per current cross-lab prompting guidance (Anthropic, + * OpenAI, Google, xAI, Cursor — researched 2026-08, distilled into the + * seeded `writing-system-prompts` skill): one-line identity first, + * named sections one concern each, cross-tool doctrine only (each + * tool's own description says how it works), and runtime facts (who + * sent a message) arriving as data on each mail rather than + * baked-in lore. The behavior clauses above (triage and teammate) are + * load-bearing and eval-anchored — restructure around them, never + * reword them casually. + */ +export const ASSISTANT_SYSTEM_PROMPT = + "You are Myra, the resident teammate agent inside this team's " + + "shared workbench.\n" + + "\n" + + "## Where you are\n" + + "You are already inside the workbench, a member of its " + + "conversation alongside the team — never speak as if you are " + + "somewhere else, and never ask to be pointed at the workbench or " + + "shown around; when you need direction, ask what they are working " + + "on. When a workbench is opened with you in it, a short opener is " + + "posted on the timeline under your name before your first turn — a " + + "hello introducing you and asking what they are working on. Never " + + "greet or introduce yourself again after it: answer their first " + + "message directly, as a teammate mid-conversation would. Never " + + "list your capabilities as a menu, and never mention memory, " + + "lookups, or missing context. Messages arrive as " + + 'mail and may carry a leading "[From: someone]" header line; treat ' + + "that line as metadata about who sent the message, never as part " + + "of the message to act on, and never echo it back in your reply.\n" + + "\n" + + "## How you speak\n" + + "Lead with the answer; two to five sentences unless the sender " + + "asks you to elaborate. " + + ASSISTANT_WELCOME_CLAUSE + + " When interviewing someone with a small set of enumerable options " + + "(2-6), use ask_user to show it as an interactive card instead of " + + "writing the options out as prose.\n" + + "\n" + + "## Deciding what to do\n" + + ASSISTANT_TRIAGE_CLAUSE + + " " + + ASSISTANT_DISCOVERY_CLAUSE + + "\n" + + "\n" + + "## Being a teammate\n" + + ASSISTANT_TEAMMATE_CLAUSE + + "\n" + + "\n" + + "## Tools\n" + + "Each tool's own description says how it works; what spans them: " + + "invoke tools only through tool calls, never by writing a JSON object " + + "with a tool name into your reply. " + + "read-only tools run free, anything that changes state asks for " + + "its own approval — so act once you have what you need instead of " + + "asking permission to use a tool. Use the team's firm memory " + + "(memory_search, memory_add, memory_list) to recall facts and " + + "decisions from earlier conversations and to record ones worth " + + "keeping — never memory_search a bare greeting, and use memory only " + + "when you actually need a fact from earlier; never fabricate a " + + "recollection when a search comes back empty, and if memory isn't " + + "set up on this deployment, proceed without mentioning it. Any MCP " + + "server connected under Plugins is " + + "reachable with mcp_list_servers, mcp_list_tools, mcp_read, and " + + "mcp_call — discover once with mcp_list_tools (pattern search when " + + "unsure which server has the tool you want); use mcp_read for " + + "read-only tools and mcp_call for anything that changes state; " + + "never guess a tool name or its arguments, and never dump a " + + "server's whole catalog into a reply. Before you write or edit any " + + "agent's system prompt or a routine's instructions, load the " + + "writing-system-prompts skill and follow it."; From 7db8f7a07d1d3b5278c43c3b5a3ceef5523ef521 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:10:22 -0700 Subject: [PATCH 2/2] feat(web): onboarding supplies the Myra deploy input (CL-8154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After T7 (#753), the onboarding page drove install over stock routes but never supplied myraDeploy, so every fresh hub stopped on the "Myra is absent" gap screen. This wires the converge loop the owner ruled on: authenticate, mint the primary tenant if needed, resolve whether it already has a usable model offering (inherited, or a previous run), and ask the operator for a provider credential only when it doesn't — never a multi-step wizard. myra-source.ts pins the deploy-source variant apps/web actually uses: neither `registry` (workflows/assistant is unpublished) nor a git-pushed `asset` "source" (the push helper shells out to a system git binary and node:fs, unavailable to a browser SPA) work today. The `asset` "tarball" variant does: PUT /api/tenants/:id/assets/:assetId/ tarballs/:filename takes raw bytes over plain fetch, no git protocol. myra-deploy.ts hand-builds the exact WorkflowDefinition JSON `buildAssistantWorkflow` would produce (from the browser-safe prompt data split out of workflows/assistant), packs it into an npm-shaped tarball with a hand-rolled USTAR writer plus the browser's own CompressionStream("gzip"), and PUTs it into a package-registry asset. provider-connect-step.tsx is the one prompt this flow asks for: three providers, each mapping to one canonical model, so connecting a provider mints exactly one offering and needs no separate "which model" pick. A tenant that already resolves a catalog offering skips this screen entirely. needs-converge.ts's HubTenant/TenantShape now carries the tenant's `domain` field the stock tenant route already returns — needed to derive Myra's trigger address, and no hub change since the field was already there. Deleted the old test asserting the pre-CL-8154 "surfaces the myra-deploy gap" behavior; replaced it with one asserting the new credential-connect landing. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/web/package.json | 1 + apps/web/src/client-bootstrap.test.ts | 1 + apps/web/src/myra-deploy.test.ts | 46 +++ apps/web/src/myra-deploy.ts | 338 ++++++++++++++++++ apps/web/src/myra-source.ts | 52 +++ apps/web/src/needs-converge.test.ts | 7 + apps/web/src/needs-converge.ts | 2 + .../src/onboarding/provider-connect-step.tsx | 182 ++++++++++ apps/web/src/pages/onboarding-page.tsx | 205 +++++++++-- apps/web/test/onboarding.test.tsx | 30 +- bun.lock | 1 + 11 files changed, 826 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/myra-deploy.test.ts create mode 100644 apps/web/src/myra-deploy.ts create mode 100644 apps/web/src/myra-source.ts create mode 100644 apps/web/src/onboarding/provider-connect-step.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 315ca10396..cca3602e15 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@corbits/api-query": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", + "@corbits/assistant-workflow": "workspace:*", "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*", diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts index 6abe950ab3..dd331713a7 100644 --- a/apps/web/src/client-bootstrap.test.ts +++ b/apps/web/src/client-bootstrap.test.ts @@ -43,6 +43,7 @@ test("session bootstrap drives the portable client manifest", async () => { id, name: "Ada", slug: "ada", + domain: "ada.example.com", parentId: null, }); }, diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts new file mode 100644 index 0000000000..60e9f7096a --- /dev/null +++ b/apps/web/src/myra-deploy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; +import { MYRA_SOURCE_CONFIG } from "./myra-source"; + +describe("buildMyraDeployInput", () => { + test("maps a published asset and the operator's offering pick to a WorkflowDeployInput", () => { + const input = buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: ["off_1", "off_2"], + defaultSourceOfferingId: "off_2", + }); + + expect(input).toEqual({ + source: { + kind: "asset", + assetId: "ast_123", + package: { format: "tarball" }, + }, + entry: MYRA_SOURCE_CONFIG.entryPath, + sourceOfferingIds: ["off_1", "off_2"], + defaultSourceOfferingId: "off_2", + pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, + }); + }); + + test("rejects an empty offering list", () => { + expect(() => + buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: [], + defaultSourceOfferingId: "off_1", + }), + ).toThrow(MyraDeployError); + }); + + test("rejects a default offering id absent from the offering list", () => { + expect(() => + buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: ["off_1"], + defaultSourceOfferingId: "off_2", + }), + ).toThrow(MyraDeployError); + }); +}); diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts new file mode 100644 index 0000000000..88cb83918e --- /dev/null +++ b/apps/web/src/myra-deploy.ts @@ -0,0 +1,338 @@ +// Builds and publishes Myra's deployable definition entirely over stock +// routes (CL-8154). See `myra-source.ts` for why this is the one +// deploy-source variant that installs today. +// +// Three steps, each a stock route: ensure the `package-registry` asset +// exists (`POST /api/tenants/:id/assets`, tolerating the 409 an +// already-provisioned tenant reports); pack the built assistant +// definition as an npm-shaped tarball and PUT it in +// (`PUT /api/tenants/:id/assets/:assetId/tarballs/:filename`); hand back +// the `WorkflowDeployInput` the caller passes to `convergeNeedsList`. +// The pure offering-ids → deploy-input mapping (`buildMyraDeployInput`) +// is split out so it can be unit-tested without a fetch or a real +// gzip/tar round-trip. +import { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "@corbits/assistant-workflow/prompt"; +import { renderWorkflowSourceTree } from "@corbits/workflows/client"; +import { type } from "arktype"; + +import { MYRA_SOURCE_CONFIG } from "./myra-source"; +import type { WorkflowDeployInput } from "./needs-list"; + +export class MyraDeployError extends Error {} + +const AssetCreatedShape = type({ id: "string" }); +const AssetListShape = type({ id: "string", name: "string" }).array(); +const TarballPutShape = type({ commit: "string", integrity: "string" }); + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors + ? `HTTP ${response.status}` + : envelope.error.userMessage; +} + +/** Idempotently ensures the `package-registry` asset Myra's tarball is + * published into, mirroring `packages/onboarding`'s `ensureWorkflowAsset` + * 409-then-list pattern. */ +export async function ensureMyraSourceAsset( + tenantId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const created = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: MYRA_SOURCE_CONFIG.assetKind, + name: MYRA_SOURCE_CONFIG.assetName, + displayName: MYRA_SOURCE_CONFIG.displayName, + }), + }, + ); + if (created.status === 201) { + const parsed = AssetCreatedShape(await created.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `Myra's source came back an unexpected shape: ${parsed.summary}`, + ); + } + return parsed.id; + } + if (created.status !== 409) { + throw new MyraDeployError( + `preparing Myra's source failed: ${await readErrorBody(created)}`, + ); + } + const listed = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=${MYRA_SOURCE_CONFIG.assetKind}&inherited=false`, + ); + if (!listed.ok) { + throw new MyraDeployError( + `checking this workbench's setup failed: ${await readErrorBody(listed)}`, + ); + } + const parsed = AssetListShape(await listed.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `this workbench's setup list came back an unexpected shape: ${parsed.summary}`, + ); + } + const existing = parsed.find( + (asset) => asset.name === MYRA_SOURCE_CONFIG.assetName, + ); + if (existing === undefined) { + throw new MyraDeployError( + "Myra's source reported a name conflict but is not listed on this workbench", + ); + } + return existing.id; +} + +// -- tar/gzip: a minimal, dependency-free USTAR + gzip writer for the +// exact two small text files `renderWorkflowSourceTree` emits. No new +// package: the browser's own `CompressionStream("gzip")` does the +// compression; only the tar container needs hand-rolling. -- + +const TAR_BLOCK_SIZE = 512; + +function tarChecksumPlaceholder(): string { + return " "; // 8 spaces, per the USTAR header spec +} + +function padOctal(value: number, width: number): string { + return value.toString(8).padStart(width - 1, "0") + "\0"; +} + +/** One USTAR header block plus its content, padded to a 512-byte + * boundary — everything `tar.Parser`'s auto-detected gzip+ustar read + * needs for a single regular-file entry. */ +function tarEntry(path: string, content: Uint8Array): Uint8Array { + if (path.length >= 100) { + throw new MyraDeployError( + `tar entry path ${JSON.stringify(path)} is too long for a USTAR header`, + ); + } + const header = new Uint8Array(TAR_BLOCK_SIZE); + const encoder = new TextEncoder(); + const writeField = (offset: number, value: string): void => { + header.set(encoder.encode(value), offset); + }; + writeField(0, path); + writeField(100, padOctal(0o644, 8)); + writeField(108, padOctal(0, 8)); + writeField(116, padOctal(0, 8)); + writeField(124, padOctal(content.length, 12)); + writeField(136, padOctal(0, 12)); + writeField(148, tarChecksumPlaceholder()); + writeField(156, "0"); // typeflag: regular file + writeField(257, "ustar\0"); + writeField(263, "00"); + + let checksum = 0; + for (const byte of header) checksum += byte; + writeField(148, padOctal(checksum, 8).slice(0, 7) + "\0 "); + + const paddedLength = + Math.ceil(content.length / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + const body = new Uint8Array(paddedLength); + body.set(content); + + const entry = new Uint8Array(header.length + body.length); + entry.set(header, 0); + entry.set(body, header.length); + return entry; +} + +/** Packs a `{ path: contents }` tree (as `renderWorkflowSourceTree` + * returns) into an npm-shaped tar archive rooted at `package/`, then + * gzips it. */ +export async function packTarball( + tree: Readonly>, +): Promise { + const encoder = new TextEncoder(); + const entries: Uint8Array[] = []; + for (const [path, contents] of Object.entries(tree)) { + entries.push(tarEntry(`package/${path}`, encoder.encode(contents))); + } + const endOfArchive = new Uint8Array(TAR_BLOCK_SIZE * 2); + const totalLength = + entries.reduce((sum, entry) => sum + entry.length, 0) + endOfArchive.length; + const tar = new Uint8Array(totalLength); + let offset = 0; + for (const entry of entries) { + tar.set(entry, offset); + offset += entry.length; + } + tar.set(endOfArchive, offset); + + const gzipStream = new Blob([tar]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(gzipStream).arrayBuffer()); +} + +function tarballFilename(): string { + const safeName = MYRA_SOURCE_CONFIG.packageName.replace( + /[^A-Za-z0-9_.@+-]/g, + "-", + ); + return `${safeName}-${MYRA_SOURCE_CONFIG.packageVersion}.tgz`; +} + +const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * The exact `WorkflowDefinition` JSON `@corbits/assistant-workflow`'s + * `buildAssistantWorkflow` would produce for a single-step, mail-triggered, + * unbounded-turn assistant — hand-built here rather than calling that + * function, because `buildAssistantWorkflow` goes through `@intx/workflow`'s + * `defineWorkflow`/`step`, which pull in `@intx/agent`'s Node-bound runtime + * (file locking) that a browser bundle cannot resolve (confirmed by a failed + * `apps/web` build importing `@corbits/assistant-workflow` directly). + * `apps/web/src/myra-deploy.test.ts` and `workflows/assistant`'s own + * `validate-push.test.ts`-style round-trip both guard this shape against + * drift from `defineWorkflow`'s own normalization + * (`vendor/intx/workflow/src/definition/workflow.ts`'s `normalize`/ + * `applyDefaultInputStep`, and `primitives.ts`'s `step`): a single step with + * no `after` gets `input: { from: "trigger.payload" }`; `triggers: + * "unbounded"` (not the numeric default) gets `drainBehavior: "wait"`; a + * bare `trigger` becomes a one-element `triggers` array. + */ +export function buildMyraDefinitionJson(triggerAddress: string): unknown { + return { + id: ASSISTANT_WORKFLOW_ID, + triggers: [{ type: "mail", to: triggerAddress }], + steps: { + [ASSISTANT_STEP_ID]: { + kind: "step", + id: ASSISTANT_STEP_ID, + agent: { + id: ASSISTANT_STEP_ID, + description: + "A general-purpose assistant that answers questions, drafts " + + "text, and reasons through problems for the team", + systemPrompt: ASSISTANT_SYSTEM_PROMPT, + toolFactories: [], + capabilities: [], + // Cosmetic label only (see `InferencePreference`'s own doc + // comment in `@intx/agent`): deploy-time inference actually + // resolves from the deploy's `sourceOfferingIds`, never from + // this field. + inference: { sources: [{ provider: "stock", model: "stock" }] }, + toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, + }, + drainBehavior: "wait", + timeout: ASSISTANT_TURN_TIMEOUT_MS, + triggers: "unbounded", + input: { from: "trigger.payload" }, + }, + }, + stepOrder: [ASSISTANT_STEP_ID], + }; +} + +/** Renders Myra's built assistant definition, packs it, and PUTs it into + * the given `package-registry` asset. Returns the published filename + * (`pin`'s `name@version` names the package inside it; the filename + * itself never leaves this module). */ +export async function publishMyraTarball( + tenantId: string, + assetId: string, + tenantDomain: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const tree = renderWorkflowSourceTree({ + packageName: MYRA_SOURCE_CONFIG.packageName, + workflowJson: JSON.stringify( + buildMyraDefinitionJson(`assistant@${tenantDomain}`), + ), + }); + const tarball = await packTarball(tree); + const filename = tarballFilename(); + const response = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets/${encodeURIComponent(assetId)}/tarballs/${encodeURIComponent(filename)}`, + { + method: "PUT", + headers: { "content-type": "application/octet-stream" }, + body: new Blob([tarball as Uint8Array]), + }, + ); + if (!response.ok) { + throw new MyraDeployError( + `publishing Myra's tarball failed: ${await readErrorBody(response)}`, + ); + } + const parsed = TarballPutShape(await response.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `Myra's tarball upload came back an unexpected shape: ${parsed.summary}`, + ); + } +} + +/** The pure mapping this module exists to get right: the operator's + * offering pick plus the asset this tenant just published into, turned + * into the exact `WorkflowDeployInput` `convergeNeedsList` needs. Split + * out from the fetch/tar plumbing above so it is unit-testable on its + * own. */ +export function buildMyraDeployInput(args: { + assetId: string; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; +}): WorkflowDeployInput { + if (args.sourceOfferingIds.length === 0) { + throw new MyraDeployError( + "at least one source offering id is required to start Myra", + ); + } + if (!args.sourceOfferingIds.includes(args.defaultSourceOfferingId)) { + throw new MyraDeployError( + "the default source offering id must be one of the supplied source offering ids", + ); + } + return { + source: { + kind: "asset", + assetId: args.assetId, + package: { format: "tarball" }, + }, + entry: MYRA_SOURCE_CONFIG.entryPath, + sourceOfferingIds: [...args.sourceOfferingIds], + defaultSourceOfferingId: args.defaultSourceOfferingId, + pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, + }; +} + +/** Orchestrates the three steps above and returns the `myraDeploy` input + * ready to hand to `bootstrapClientSession`/`convergeNeedsList`. */ +export async function deployMyraSource( + args: { + tenantId: string; + tenantDomain: string; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; + }, + fetchImpl: typeof fetch = fetch, +): Promise { + const assetId = await ensureMyraSourceAsset(args.tenantId, fetchImpl); + await publishMyraTarball( + args.tenantId, + assetId, + args.tenantDomain, + fetchImpl, + ); + return buildMyraDeployInput({ + assetId, + sourceOfferingIds: args.sourceOfferingIds, + defaultSourceOfferingId: args.defaultSourceOfferingId, + }); +} diff --git a/apps/web/src/myra-source.ts b/apps/web/src/myra-source.ts new file mode 100644 index 0000000000..5349016287 --- /dev/null +++ b/apps/web/src/myra-source.ts @@ -0,0 +1,52 @@ +// Myra's deploy source, pinned to what this hub can actually install +// today (CL-8154). +// +// `@corbits/assistant-workflow` (workflows/assistant) is `private: true` +// and unpublished, so `WorkflowDefinitionRegistrySource` (the `registry` +// variant) has no external npm registry to resolve it from yet — that is +// CL-8151's job. The package also exports builder functions +// (`buildAssistantWorkflow`), not a default-exported `WorkflowDefinition`, +// so pointing a git `asset` "source" install at the real workspace +// package would fail the source-tree installer's shape checks too. +// +// The one variant that installs cleanly today, entirely over stock +// routes and with no hub or seed change, is the same shape +// `packages/onboarding`'s `seedTenant` and the e2e harness already push: +// the built definition rendered as the two-file source package +// `@corbits/workflows`' `renderWorkflowSourceTree` emits, published as a +// `tarball` inside a stock `package-registry` asset via `PUT +// /api/tenants/:id/assets/:assetId/tarballs/:filename` — a route that +// takes raw tarball bytes over plain `fetch`, no git protocol, no +// system git binary, so it is the only variant reachable from a browser +// SPA like `apps/web`. `myra-deploy.ts` drives it. +import { type } from "arktype"; + +export const MyraSourceConfig = type({ + assetKind: "'package-registry'", + assetName: "string > 0", + displayName: "string > 0", + packageName: "string > 0", + packageVersion: "string > 0", + entryPath: "string > 0", +}); +export type MyraSourceConfig = typeof MyraSourceConfig.infer; + +const parsed = MyraSourceConfig({ + assetKind: "package-registry", + // Distinct from `SETUP_AGENT_ASSET_NAME` ("assistant", a `workflow`-kind + // asset `seedTenant` owns): this is a `package-registry`-kind asset, + // a different asset namespace, so it never collides with a seeded + // tenant's own asset. + assetName: "myra-deploy-source", + displayName: "Myra", + // The rendered package's own name — never `@corbits/assistant-workflow` + // itself, which names the real, un-deployable workspace package this + // module explicitly does not source from (see the module doc above). + packageName: "@workbench-onboarding/myra", + packageVersion: "0.0.0", + entryPath: "./workflow.js", +}); +if (parsed instanceof type.errors) { + throw new Error(`invalid MYRA_SOURCE_CONFIG literal: ${parsed.summary}`); +} +export const MYRA_SOURCE_CONFIG: MyraSourceConfig = parsed; diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index bb34189056..aed17db44f 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -18,6 +18,7 @@ const primary = { id: "tnt_primary", name: "Ada", slug: "ada", + domain: "ada.example.com", parentId: null, }; @@ -70,6 +71,7 @@ function snapshotWithChild(childId: string): HubSnapshot { id: childId, name: "Atlas", slug: "ada-atlas", + domain: "ada.example.com", parentId: "tnt_primary", }, ], @@ -115,6 +117,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }, @@ -304,6 +307,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }), inviteMember: () => Promise.resolve(), @@ -412,6 +416,7 @@ describe("writes after gap checks", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }; @@ -517,6 +522,7 @@ describe("thread-native DM derivation", () => { id: `tnt_${input.slug}`, name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }, @@ -805,6 +811,7 @@ describe("stock-only fetch hub", () => { id: "tnt_atlas", name: "Atlas", slug: "ada-atlas", + domain: "ada.example.com", parentId: "tnt_primary", }, "POST /api/tenants/tnt_atlas/mailbox/messages": { diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index 04eb59b78f..6c9ca37946 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -38,6 +38,7 @@ export type HubTenant = { name: string; slug: string; parentId: string | null; + domain: string; }; export type HubRole = { id: string; name: string }; @@ -468,6 +469,7 @@ const TenantShape = type({ id: "string", name: "string", slug: "string", + domain: "string", "parentId?": "string | null", }); const RoleShape = type({ id: "string", name: "string" }); diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx new file mode 100644 index 0000000000..9d2b9c4533 --- /dev/null +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -0,0 +1,182 @@ +// The credential + model-offering step this onboarding page runs before +// installing Myra (CL-8154): connects a provider credential through the +// stock native credentials/providers routes (`@corbits/inference-settings`' +// `shadowOffering`), then derives the exactly-one offering it mints into +// `sourceOfferingIds`/`defaultSourceOfferingId` — no separate offering +// picker, because a freshly connected provider names exactly one model. +// A tenant that already resolves a catalog offering (inherited, or from +// an earlier run of this same step) skips this UI entirely: see +// `resolveExistingOffering` in `onboarding-page.tsx`. +import { Button, Input, RadioGroup, RadioOption } from "@corbits/react-ui"; +import { + getResolvedCatalog, + shadowOffering, +} from "@corbits/inference-settings"; +import { reportError } from "@corbits/error-sink"; +import { useState } from "react"; +import type { FormEvent } from "react"; + +import type { ModelProviderPlugin } from "@intx/types"; + +export type ProviderOption = { + readonly plugin: ModelProviderPlugin; + readonly label: string; + readonly description: string; + readonly canonicalName: string; + readonly modelDisplayName: string; + readonly baseURL: string; + readonly keyHint: string; +}; + +// Deliberately small: `ModelProviderPlugin` names the adapters this hub +// ships (`vendor/intx/types/src/catalog.ts`), and each option below picks +// one canonical model so connecting a provider mints exactly one +// offering — no second "which model" prompt (per the CL-8154 ruling, +// the flow asks only where a human input is genuinely required). +// Widening this list (more models per provider, an `openai-compatible` +// custom-baseURL card) is a follow-up, not a blocker for a working +// install. +export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ + { + plugin: "anthropic", + label: "Anthropic", + description: "Claude models, direct from Anthropic.", + canonicalName: "claude-sonnet-4-5", + modelDisplayName: "Claude Sonnet 4.5", + baseURL: "https://api.anthropic.com", + keyHint: "sk-ant-", + }, + { + plugin: "openai", + label: "OpenAI", + description: "GPT models, direct from OpenAI.", + canonicalName: "gpt-5", + modelDisplayName: "GPT-5", + baseURL: "https://api.openai.com/v1", + keyHint: "sk-", + }, + { + plugin: "google-genai", + label: "Google", + description: "Gemini models, direct from Google.", + canonicalName: "gemini-2.5-pro", + modelDisplayName: "Gemini 2.5 Pro", + baseURL: "https://generativelanguage.googleapis.com", + keyHint: "AIza", + }, +]; + +export type ExistingOffering = { + readonly sourceOfferingIds: readonly string[]; + readonly defaultSourceOfferingId: string; +}; + +/** A tenant that already resolves a catalog offering (inherited from an + * ancestor, or minted by an earlier run of this step) needs no UI at + * all: every visible offering becomes `sourceOfferingIds`, and the + * lowest-priority one is the default — the same rule + * `resolveRealSourceOfferingIds` in `packages/onboarding` applies. */ +export async function resolveExistingOffering( + tenantId: string, +): Promise { + const models = await getResolvedCatalog(tenantId); + const offerings = models + .flatMap((model) => model.offerings) + .sort((a, b) => a.priority - b.priority); + const defaultSourceOfferingId = offerings[0]?.offeringId; + if (defaultSourceOfferingId === undefined) return null; + return { + sourceOfferingIds: offerings.map((offering) => offering.offeringId), + defaultSourceOfferingId, + }; +} + +export function ProviderConnectStep({ + tenantId, + onConnected, + onError, +}: { + readonly tenantId: string; + readonly onConnected: (offering: ExistingOffering) => void; + readonly onError: (message: string) => void; +}) { + const [selected, setSelected] = useState( + PROVIDER_OPTIONS[0]?.plugin ?? "anthropic", + ); + const [apiKey, setApiKey] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const option = + PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? + PROVIDER_OPTIONS[0]; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (option === undefined || apiKey.trim().length === 0 || submitting) { + return; + } + setSubmitting(true); + try { + const created = await shadowOffering(tenantId, { + canonicalName: option.canonicalName, + modelDisplayName: option.modelDisplayName, + providerName: option.label, + plugin: option.plugin, + baseURL: option.baseURL, + apiKey: apiKey.trim(), + priority: 0, + }); + onConnected({ + sourceOfferingIds: [created.id], + defaultSourceOfferingId: created.id, + }); + } catch (cause) { + const refId = reportError(cause, { + operation: "onboarding.connect-provider", + tenantId, + }); + onError( + `${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`, + ); + } finally { + setSubmitting(false); + } + } + + return ( +
void handleSubmit(event)} + > + setSelected(value as ModelProviderPlugin)} + > + {PROVIDER_OPTIONS.map((candidate) => ( + + ))} + + + +
+ ); +} diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 8f2b364224..8c896af458 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,28 +1,51 @@ -// The setup gate (CL-8112/CL-8131): the screen a signed-in session lands -// on when the hub reports setup-required. It reads the hub's native -// setup-status route, and a hub that already has tenants bounces -// straight into the shell (`/`). An empty hub drives the browser -// installer itself: `bootstrapClientSession` mints the account's primary -// tenant over the stock `POST /api/tenants` route and converges Myra -// onto it, all over stock routes — the hub never seeds anything. A -// converged install lands on `/`; a stock capability gap (most commonly -// "no myraDeploy configured yet") renders here so the operator sees -// exactly what stock Interchange is missing, with a retry; a broken -// status read blocks with retry too. +// The setup gate (CL-8112/CL-8131/CL-8154): the screen a signed-in +// session lands on when the hub reports setup-required. It reads the +// hub's native setup-status route, and a hub that already has tenants +// bounces straight into the shell (`/`). An empty hub drives the +// browser installer itself, as one converge loop over stock routes, +// asking the operator only where a human input is genuinely required: +// +// 1. mint the account's primary tenant (stock `POST /api/tenants`) if +// it does not already own one; +// 2. resolve a catalog offering to deploy Myra against — if the +// tenant already resolves one (inherited, or a previous run of +// this step), skip straight past; otherwise ask the operator to +// connect exactly one provider credential (`ProviderConnectStep`); +// 3. publish Myra's tarball-sourced deploy input for that offering +// (`deployMyraSource`) and hand it to `bootstrapClientSession` as +// `myraDeploy`. +// +// A converged install lands on `/`; a stock capability gap this loop did +// not anticipate, or a hard failure at any step, renders here with a +// retry — a gap is not "ready". import { Button, EmptyState } from "@corbits/react-ui"; import { WarningCircle } from "@corbits/icons"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; -import { runPortableClientBootstrap } from "../client-bootstrap"; +import { + ensurePrimaryTenant, + runPortableClientBootstrap, +} from "../client-bootstrap"; +import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; +import { deployMyraSource } from "../myra-deploy"; import { useNavigate } from "../navigation"; import { triggerFirstLoginProvisioning } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; +import { + ProviderConnectStep, + resolveExistingOffering, + type ExistingOffering, +} from "../onboarding/provider-connect-step"; +import type { WorkflowDeployInput } from "../needs-list"; import type { SessionUser } from "../session"; type GateState = | { readonly phase: "checking" } - | { readonly phase: "installing" } + | { readonly phase: "resolving-tenant" } + | { readonly phase: "provider-setup"; readonly tenantId: string } + | { readonly phase: "publishing-myra" } + | { readonly phase: "installing"; readonly myraDeploy: WorkflowDeployInput } | { readonly phase: "setup-pending"; readonly message: string; @@ -39,8 +62,8 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { const [state, setState] = useState({ phase: "checking" }); // One status read per landing (plus each manual recheck): a hub that - // already has tenants means setup is done; an empty hub drives the - // installer immediately rather than showing a dead-end panel. + // already has tenants means setup is done; an empty hub starts the + // converge loop immediately rather than showing a dead-end panel. const checkStatus = useCallback(() => { setState({ phase: "checking" }); void triggerFirstLoginProvisioning().then((result) => { @@ -57,7 +80,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { } else if (result.kind === "existing-member") { navigate("/"); } else { - setState({ phase: "installing" }); + setState({ phase: "resolving-tenant" }); } }); }, [navigate]); @@ -65,14 +88,107 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { checkStatus(); }, [checkStatus]); - // The installer itself: runs once the status read confirms setup is - // required. A converged install hands off to the shell; a stock - // capability gap or a hard failure surfaces here instead of retrying - // silently forever. + // Step 1: mint the primary tenant if this account does not already + // own one, then move to the offering-resolution step. Stays a + // separate phase from "installing" (which still runs the full + // `bootstrapClientSession` converge for the rest of the needs list) + // because a credential connect needs a tenant id to write against. + useEffect(() => { + if (state.phase !== "resolving-tenant") return; + let cancelled = false; + const hub = createFetchStockHub(); + void (async () => { + await ensurePrimaryTenant(user, hub); + const owned = await findOwnedTenants(hub); + const primary = owned.find((tenant) => tenant.parentId === null); + if (primary === undefined) { + throw new Error( + "Sign-in is expected to leave exactly one owned top-level home behind, but this session shows none.", + ); + } + return primary; + })().then( + (primary) => { + if (cancelled) return; + void resolveExistingOffering(primary.id).then( + (existing) => { + if (cancelled) return; + if (existing !== null) { + setState({ phase: "publishing-myra" }); + void publishAndInstall(primary.id, primary.domain, existing); + return; + } + setState({ phase: "provider-setup", tenantId: primary.id }); + }, + (error: unknown) => { + if (cancelled) return; + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Checking your model access hit a snag.", + }); + }, + ); + }, + (error: unknown) => { + if (cancelled) return; + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Setting up your workbench hit a snag.", + }); + }, + ); + 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. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.phase, user]); + + // Step 3: publish Myra's deploy input for the resolved offering, then + // hand off to the installing phase. Shared by both the + // already-resolved-offering path (above) and the operator-connected + // path (`ProviderConnectStep`'s `onConnected` below). + function publishAndInstall( + tenantId: string, + tenantDomain: string, + offering: ExistingOffering, + ): Promise { + return deployMyraSource({ + tenantId, + tenantDomain, + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + }).then( + (myraDeploy) => setState({ phase: "installing", myraDeploy }), + (error: unknown) => { + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Publishing Myra's source hit a snag.", + }); + }, + ); + } + + // Step 4: the installer itself, run once a `myraDeploy` 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; - void runPortableClientBootstrap(user).then( + void runPortableClientBootstrap(user, { + myraDeploy: state.myraDeploy, + }).then( (result) => { if (cancelled) return; if (result.kind === "ready") { @@ -97,9 +213,9 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { return () => { cancelled = true; }; - }, [state.phase, user, navigate]); + }, [state, user, navigate]); - if (state.phase === "checking") { + if (state.phase === "checking" || state.phase === "resolving-tenant") { return (
@@ -116,7 +232,48 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { ); } - if (state.phase === "installing") { + if (state.phase === "provider-setup") { + return ( + +
+

Connect a model provider

+

+ Myra needs one working inference credential before she can start. +

+
+ { + const tenantId = state.tenantId; + setState({ phase: "publishing-myra" }); + // 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); + }); + }} + onError={(message) => setState({ phase: "error", message })} + /> +
+
+
+ ); + } + + if (state.phase === "publishing-myra" || state.phase === "installing") { return (
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 3683d76f66..104bc10e2a 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -320,13 +320,14 @@ describe("the setup gate", () => { ); } - test("an empty hub drives the installer, which mints the primary tenant then surfaces the myra-deploy gap", async () => { - // The installer's own flow (CL-8131): the first pass finds no owned - // primary tenant, mints one over stock `POST /api/tenants`, then - // converges again — landing on the "no myraDeploy configured" gap - // since this test supplies no deploy inputs and the fresh tenant has - // no Myra yet. The gate renders that gap, and never navigates: a gap - // is not "ready". + test("an empty hub drives the installer, which mints the primary tenant then asks the operator to connect a provider (CL-8154)", async () => { + // The installer's own flow (CL-8131, extended by CL-8154): the first + // pass finds no owned primary tenant, mints one over stock + // `POST /api/tenants`, then checks whether it already resolves a + // catalog offering. A fresh tenant has none, so the gate renders the + // credential-connect step rather than the old "no myraDeploy + // configured" gap — the whole point of CL-8154 is that this page + // supplies `myraDeploy` itself instead of stopping there. let ownsPrimary = false; const container = document.createElement("div"); document.body.appendChild(container); @@ -364,6 +365,7 @@ describe("the setup gate", () => { id: "tnt_primary", name: "Ada's Workbench", slug: "ada", + domain: "ada.workbench.localhost", parentId: null, }, 201, @@ -374,12 +376,16 @@ describe("the setup gate", () => { id: "tnt_primary", name: "Ada's Workbench", slug: "ada", + domain: "ada.workbench.localhost", parentId: null, }); } if (path === "/api/tenants/tnt_primary/principals") { return json({ data: [], nextCursor: null }); } + if (path === "/api/tenants/tnt_primary/models") { + return json([]); + } // Anything outside the mocked stock routes is a failure: the gate // must never touch the deleted `/api/onboarding/*` routes. throw new Error(`unexpected fetch: ${url}`); @@ -388,16 +394,10 @@ describe("the setup gate", () => { try { await settle(root, gateElement(navigate)); - expect(container.textContent).toContain("Set up your workbench"); - expect(container.textContent).toContain("Check again"); + expect(container.textContent).toContain("Connect a model provider"); expect(calls).toEqual([]); expect(seen).toContain("/api/setup/status"); - expect( - seen.some( - (url) => - url.startsWith("/api/tenants") && !url.includes("principals"), - ), - ).toBe(true); + expect(seen).toContain("/api/tenants/tnt_primary/models"); } finally { act(() => root.unmount()); container.remove(); diff --git a/bun.lock b/bun.lock index 377c934fa8..add9edbd9a 100644 --- a/bun.lock +++ b/bun.lock @@ -157,6 +157,7 @@ "@corbits/api-query": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", + "@corbits/assistant-workflow": "workspace:*", "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*",