Merge web-first autonomous project loop

Integrates project request dispatch, Effect primitives, AgentOS checkout preparation, Gitea PR lifecycle, desktop and mobile project-loop UI, and CPA smoke contracts.

Refs #4 #14 #17.
This commit is contained in:
2026-07-23 21:51:00 +00:00
96 changed files with 10380 additions and 821 deletions

View File

@@ -12,7 +12,7 @@
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@code/ui/components",
"utils": "@code/ui/lib/utils", "utils": "@code/ui/lib/utils",
"ui": "@code/ui/components", "ui": "@code/ui/components",
"lib": "@/lib", "lib": "@/lib",

View File

@@ -7,12 +7,13 @@
"dev": "react-router dev", "dev": "react-router dev",
"dev:tailscale": "react-router dev --host 0.0.0.0", "dev:tailscale": "react-router dev --host 0.0.0.0",
"start": "react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc" "check-types": "react-router typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@code/auth": "workspace:*", "@code/auth": "workspace:*",
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*", "@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9", "@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9",
@@ -40,7 +41,7 @@
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vitest": "catalog:", "vite-tsconfig-paths": "^6.1.1",
"vite-tsconfig-paths": "^6.1.1" "vitest": "catalog:"
} }
} }

View File

@@ -1,5 +1,4 @@
import { MessageHeader } from "@code/ui/components/message"; import { MobileChatAssistantLabel } from "@code/ui/components/mobile-chat";
import { Sparkles } from "lucide-react";
import type { AssistantResponseState } from "@/lib/chat/types"; import type { AssistantResponseState } from "@/lib/chat/types";
@@ -8,20 +7,18 @@ interface AssistantIdentityProps {
} }
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => ( export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
<MessageHeader className="mb-1.5 gap-2.5 px-0 text-xs font-medium text-foreground/70"> <MobileChatAssistantLabel
<span className="zopu-mark flex size-6 items-center justify-center rounded-lg"> state={
<Sparkles className="size-3.5" /> state ? (
</span> <span className="response-state" data-state={state}>
Zopu <span aria-hidden="true" className="response-state-dots">
{state && ( <i />
<span className="response-state" data-state={state}> <i />
<span aria-hidden="true" className="response-state-dots"> <i />
<i /> </span>
<i /> {state === "thinking" ? "Thinking" : "Writing"}
<i />
</span> </span>
{state === "thinking" ? "Thinking" : "Writing"} ) : undefined
</span> }
)} />
</MessageHeader>
); );

View File

@@ -1,10 +1,5 @@
import { import { MobileChatComposer } from "@code/ui/components/mobile-chat";
InputGroup, import { LoaderCircle } from "lucide-react";
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@code/ui/components/input-group";
import { CircleAlert, LoaderCircle, Send } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer"; import { useChatComposer } from "@/hooks/chat/use-chat-composer";
@@ -15,90 +10,31 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
status === "connecting" || status === "submitted" || status === "streaming"; status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend }); const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = ( let statusMessage: ReactNode;
<span className="hidden sm:inline">
Enter to send · Shift + Enter for a new line
</span>
);
if (busy) { if (busy) {
statusContent = ( statusMessage = (
<> <span className="inline-flex items-center gap-1.5">
<LoaderCircle className="size-3.5 animate-spin" /> <LoaderCircle className="size-3 animate-spin" />
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"} {status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</>
);
}
if (error) {
statusContent = (
<span
id="composer-error"
className="flex items-center gap-1.5 text-destructive"
>
<CircleAlert className="size-3.5" />
{error.message || "Message failed to send"}
</span> </span>
); );
} }
return ( return (
<div className="composer-dock shrink-0 pb-[env(safe-area-inset-bottom)]"> <MobileChatComposer
<form busy={busy}
aria-label="Send a message" canSend={composer.canSend}
className="mx-auto w-full max-w-3xl px-3 pb-3 sm:px-5 sm:pb-5" errorMessage={
onSubmit={composer.handleSubmit} error ? error.message || "Message failed to send" : undefined
> }
<InputGroup onSubmit={composer.handleSubmit}
className={`composer-shell overflow-hidden rounded-[1.5rem] border-border/70 bg-card/95 shadow-[0_16px_50px_-18px_rgb(0_0_0/0.38)] backdrop-blur-xl transition-[border-color,box-shadow] focus-within:border-foreground/20 focus-within:shadow-[0_18px_60px_-20px_rgb(0_0_0/0.48)] dark:bg-card/90 ${ statusMessage={statusMessage}
composer.isFocused textareaProps={{
? "composer-shell-expanded" ...composer.inputProps,
: "composer-shell-compact" "aria-describedby": error ? "composer-error" : undefined,
}`} "aria-invalid": error ? true : undefined,
data-disabled={busy || undefined} disabled: busy,
> }}
<InputGroupTextarea />
{...composer.inputProps}
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label="Message Zopu"
className={`max-h-40 px-4 text-base leading-6 transition-[min-height,padding] duration-200 placeholder:text-muted-foreground/70 sm:px-5 ${
composer.isFocused
? "min-h-24 pb-2 pt-4"
: "min-h-14 py-[0.95rem] pr-2"
}`}
disabled={busy}
placeholder="Ask anything…"
rows={1}
/>
<InputGroupAddon
align="inline-end"
className={`shrink-0 self-end pr-2 transition-[padding] duration-200 ${
composer.isFocused ? "pb-2.5" : "pb-1.5"
}`}
>
<InputGroupButton
aria-label="Send message"
className="size-11 shrink-0 rounded-2xl bg-foreground text-background transition-all hover:scale-[1.03] hover:bg-foreground/85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground sm:size-10 sm:rounded-xl"
disabled={!composer.canSend}
size="icon-sm"
type="submit"
variant="default"
>
{busy ? (
<LoaderCircle className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div className="mt-2 flex min-h-4 items-center justify-center px-2 text-center text-[10px] text-muted-foreground/80 sm:text-[11px]">
{composer.isFocused || busy || error ? (
<span className="flex items-center gap-2">{statusContent}</span>
) : (
<span>Zopu can make mistakes. Check important information.</span>
)}
</div>
</form>
</div>
); );
}; };

View File

@@ -1,15 +1,6 @@
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button"; import { Button } from "@code/ui/components/button";
import { import { MobileChatMark } from "@code/ui/components/mobile-chat";
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@code/ui/components/empty";
import { MessageGroup } from "@code/ui/components/message";
import { MessageScrollerItem } from "@code/ui/components/message-scroller";
import { Sparkles } from "lucide-react";
import { SUGGESTIONS } from "@/lib/chat/constants"; import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types"; import type { ChatConversationProps } from "@/lib/chat/types";
@@ -25,23 +16,21 @@ export const ChatConversation = ({
}: ChatConversationProps) => { }: ChatConversationProps) => {
if (historyReady && messages.length === 0) { if (historyReady && messages.length === 0) {
return ( return (
<Empty className="min-h-full items-start justify-end px-1 pb-7 pt-20 sm:items-center sm:justify-center sm:px-6 sm:py-20"> <ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
<EmptyMedia className="zopu-mark size-11 rounded-2xl" variant="icon"> <MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
<Sparkles className="size-5" /> <div className="space-y-1">
</EmptyMedia> <h2 className="text-[22px] font-semibold tracking-[-0.035em]">
<EmptyHeader className="items-start text-left sm:items-center sm:text-center">
<EmptyTitle className="text-2xl font-semibold tracking-[-0.035em] sm:text-3xl">
What can I help with? What can I help with?
</EmptyTitle> </h2>
<EmptyDescription className="max-w-md text-[15px] leading-6"> <p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
Think, write, plan, or explore an idea with Zopu. Think, write, plan, or explore an idea with Zopu.
</EmptyDescription> </p>
</EmptyHeader> </div>
<EmptyContent className="mt-3 grid w-full max-w-xl gap-2 sm:grid-cols-3"> <div className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => ( {SUGGESTIONS.map(([title, prompt]) => (
<Button <Button
key={title} key={title}
className="group h-auto min-h-16 w-full flex-col items-start justify-center gap-0.5 rounded-2xl border-border/60 bg-card/60 px-4 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-foreground/20 hover:bg-card hover:shadow-md sm:min-h-24" className="group h-auto min-h-14 w-full flex-col items-start justify-center gap-0.5 rounded-[16px] border-[#e7e7e4] bg-[#f7f7f5] px-4 py-3 text-left shadow-none transition-colors active:bg-[#eeeeeb]"
onClick={() => onSuggestion(prompt)} onClick={() => onSuggestion(prompt)}
variant="outline" variant="outline"
> >
@@ -51,27 +40,17 @@ export const ChatConversation = ({
</span> </span>
</Button> </Button>
))} ))}
</EmptyContent> </div>
</Empty> </ConversationEmptyState>
); );
} }
return ( return (
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8"> <div className="flex min-w-0 flex-col gap-5 pb-5">
{messages.map((message, index) => ( {messages.map((message) => (
<MessageScrollerItem <ChatMessage key={message.id} message={message} />
key={message.id}
messageId={message.id}
scrollAnchor={index === messages.length - 1}
>
<ChatMessage message={message} />
</MessageScrollerItem>
))} ))}
{status === "submitted" && ( {status === "submitted" && <ChatThinkingResponse />}
<MessageScrollerItem scrollAnchor> </div>
<ChatThinkingResponse />
</MessageScrollerItem>
)}
</MessageGroup>
); );
}; };

View File

@@ -1,32 +1,11 @@
import { Sparkles } from "lucide-react"; import { MobileChatHeader } from "@code/ui/components/mobile-chat";
import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants"; import { STATUS_COPY } from "@/lib/chat/constants";
import { getStatusDotClass } from "@/lib/chat/transforms";
import type { ChatHeaderProps } from "@/lib/chat/types"; import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => ( export const ChatHeader = ({ status }: ChatHeaderProps) => (
<header className="chat-header flex h-14 shrink-0 items-center border-b border-border/60 px-4 sm:h-16 sm:px-6"> <MobileChatHeader
<div className="mx-auto flex w-full max-w-5xl items-center justify-between"> active={status !== "connecting" && status !== "error"}
<div className="flex min-w-0 items-center gap-3"> statusLabel={STATUS_COPY[status]}
<div className="zopu-mark flex size-9 shrink-0 items-center justify-center rounded-xl"> />
<Sparkles className="size-4" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight sm:text-[15px]">
Zopu
</h1>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${getStatusDotClass(status)}`}
/>
<span aria-live="polite">{STATUS_COPY[status]}</span>
</p>
</div>
</div>
<p className="rounded-full border border-border/60 bg-card/50 px-2.5 py-1 text-[10px] font-medium tracking-wide text-muted-foreground sm:text-[11px]">
{MODEL_LABEL}
</p>
</div>
</header>
); );

View File

@@ -1,7 +1,13 @@
import { Bubble, BubbleContent } from "@code/ui/components/bubble"; import {
import { Message, MessageContent } from "@code/ui/components/message"; Message,
MessageContent,
MessageResponse,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Streamdown } from "streamdown";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types"; import type { ChatMessageProps } from "@/lib/chat/types";
@@ -21,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
let content: ReactNode = text; let content: ReactNode = text;
if (!isUser) { if (!isUser) {
content = text ? ( content = text ? (
<Streamdown <MessageResponse
caret={isStreaming ? "block" : undefined} caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0" className="chat-markdown min-w-0"
isAnimating={isStreaming} isAnimating={isStreaming}
> >
{text} {text}
</Streamdown> </MessageResponse>
) : ( ) : (
<div className="thinking-line" aria-label="Zopu is preparing a response"> <div className="thinking-line" aria-label="Zopu is preparing a response">
<span /> <span />
@@ -37,41 +43,29 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
); );
} }
const sender = isUser ? "user" : "assistant";
return ( return (
<Message <Message className="max-w-full gap-0" from={message.role}>
align={isUser ? "end" : "start"} <MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
className="chat-message text-[15px] md:text-base" <MobileChatMessage className="chat-message" sender={sender}>
> <div className={isUser ? "max-w-full" : "w-full min-w-0"}>
<MessageContent {!isUser && (
className={ <AssistantIdentity state={isStreaming ? "writing" : undefined} />
isUser
? "max-w-[88%] sm:max-w-[76%] md:max-w-[68%]"
: "max-w-full md:max-w-[92%]"
}
>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<Bubble
align={isUser ? "end" : "start"}
variant={isUser ? "secondary" : "ghost"}
className="max-w-full"
>
<BubbleContent
className={
isUser
? "rounded-[1.35rem] rounded-br-md border border-border/60 bg-secondary/80 px-4 py-2.5 leading-6 text-foreground shadow-sm whitespace-pre-wrap dark:bg-secondary/70"
: "w-full max-w-none overflow-visible leading-7 text-foreground/95"
}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)} )}
{content} <MobileChatBubble
</BubbleContent> className={isUser ? "whitespace-pre-wrap" : undefined}
</Bubble> sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent> </MessageContent>
</Message> </Message>
); );

View File

@@ -1,10 +1,8 @@
import { import {
MessageScroller, Conversation,
MessageScrollerButton, ConversationContent,
MessageScrollerContent, ConversationScrollButton,
MessageScrollerProvider, } from "@code/ui/components/ai-elements/conversation";
MessageScrollerViewport,
} from "@code/ui/components/message-scroller";
import { useChatAgent } from "@/hooks/chat/use-chat-agent"; import { useChatAgent } from "@/hooks/chat/use-chat-agent";
@@ -17,36 +15,24 @@ export const ChatPage = () => {
const handleSendMessage = (message: string) => agent.sendMessage(message); const handleSendMessage = (message: string) => agent.sendMessage(message);
return ( return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-background"> <section className="chat-surface flex h-full min-h-0 flex-col bg-[#fefefe]">
<ChatHeader status={agent.status} /> <ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1"> <main aria-label="Conversation" className="min-h-0 flex-1">
<MessageScrollerProvider <Conversation className="ai-conversation-mobile h-full">
autoScroll <ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
defaultScrollPosition="end" <ChatConversation
scrollEdgeThreshold={96} historyReady={agent.historyReady}
scrollMargin={24} messages={agent.messages}
> onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
<MessageScroller> status={agent.status}
<MessageScrollerViewport aria-label="Chat messages">
<MessageScrollerContent className="mx-auto w-full max-w-3xl px-4 pb-8 pt-7 sm:px-6 sm:pb-10 sm:pt-10">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) =>
void handleSendMessage(suggestion)
}
status={agent.status}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton
aria-label="Scroll to latest message"
className="bottom-4 size-10 rounded-full border border-border/70 bg-card/95 shadow-lg backdrop-blur"
direction="end"
/> />
</MessageScroller> </ConversationContent>
</MessageScrollerProvider> <ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
/>
</Conversation>
</main> </main>
<ChatComposer <ChatComposer

View File

@@ -1,16 +1,29 @@
import { Message, MessageContent } from "@code/ui/components/message"; import {
Message,
MessageContent,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import { AssistantIdentity } from "./assistant-identity"; import { AssistantIdentity } from "./assistant-identity";
export const ChatThinkingResponse = () => ( export const ChatThinkingResponse = () => (
<Message align="start" className="chat-message"> <Message className="max-w-full gap-0" from="assistant">
<MessageContent className="max-w-full md:max-w-[92%]"> <MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
<AssistantIdentity state="thinking" /> <MobileChatMessage className="chat-message" sender="assistant">
<div className="thinking-line" aria-label="Zopu is thinking"> <div className="w-full min-w-0">
<span /> <AssistantIdentity state="thinking" />
<span /> <MobileChatBubble sender="assistant">
<span /> <div className="thinking-line" aria-label="Zopu is thinking">
</div> <span />
<span />
<span />
</div>
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent> </MessageContent>
</Message> </Message>
); );

View File

@@ -1,4 +1,11 @@
import { Check, LoaderCircle, Terminal, TriangleAlert } from "lucide-react"; import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
} from "@code/ui/components/ai-elements/tool";
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
import { Search, SquareTerminal } from "lucide-react";
import type { ChatToolCallProps } from "@/lib/chat/types"; import type { ChatToolCallProps } from "@/lib/chat/types";
@@ -7,35 +14,47 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => {
typeof part.input === "string" typeof part.input === "string"
? part.input ? part.input
: JSON.stringify(part.input, null, 2); : JSON.stringify(part.input, null, 2);
let Icon = LoaderCircle; let status = "running";
let status = "Running"; let tone: "error" | "neutral" | "success" = "neutral";
if (part.state === "output-available") { if (part.state === "output-available") {
detail = detail =
typeof part.output === "string" typeof part.output === "string"
? part.output ? part.output
: JSON.stringify(part.output, null, 2); : JSON.stringify(part.output, null, 2);
Icon = Check; status = "done";
status = "Completed"; tone = "success";
} else if (part.state === "output-error") { } else if (part.state === "output-error") {
detail = part.errorText; detail = part.errorText;
Icon = TriangleAlert; status = "failed";
status = "Failed"; tone = "error";
} }
const isRunning = part.state === "input-available";
const isSearch = part.toolName.toLowerCase().includes("search");
const output = part.state === "output-available" ? part.output : undefined;
const errorText = part.state === "output-error" ? part.errorText : undefined;
return ( return (
<details className="group/tool overflow-hidden rounded-xl border border-border/60 bg-card/45 text-xs"> <Tool className="mb-0 w-full border-0" defaultOpen>
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-muted-foreground marker:hidden"> <ToolContent className="space-y-0 p-0">
<Terminal className="size-3.5 shrink-0" /> <MobileChatToolCall
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85"> detail={detail}
{part.toolName} icon={
</span> isSearch ? (
<span>{status}</span> <Search className="size-3" strokeWidth={2.2} />
<Icon className={`size-3.5 ${isRunning ? "animate-spin" : ""}`} /> ) : (
</summary> <SquareTerminal className="size-3" strokeWidth={2.2} />
<pre className="max-h-72 overflow-auto border-t border-border/50 bg-background/60 px-3 py-2 font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground"> )
{detail} }
</pre> status={status}
</details> tone={tone}
toolName={part.toolName}
/>
<div className="sr-only">
<ToolInput input={part.input} />
<ToolOutput errorText={errorText} output={output} />
</div>
</ToolContent>
</Tool>
); );
}; };

View File

@@ -0,0 +1,102 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "work-list"
| "work-unit-detail";
interface MobileFlowPageProps {
screen: MobileFlowScreen;
}
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const { workUnitId } = useParams();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
const workspace = useMobileWorkspace({
onCreateIssue: (title, body) =>
projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage,
});
const workPath = "/work";
const chatPath = "/chat";
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
navigate("/dashboard");
return;
}
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(`/work/${targetId}`);
};
const screenProps = {
composerValue: workspace.composerValue,
createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data,
onBack: () => navigate(workPath),
onComposerChange: workspace.handleComposerChange,
onComposerSubmit: workspace.handleComposerSubmit,
onCreateBodyChange: workspace.setIssueBody,
onCreateIssue: workspace.handleCreateIssueSubmit,
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
? () =>
void projectWorkspace.raiseIssueFromSignal(
projectWorkspace.data.latestSignal?.id ?? ""
)
: undefined,
onCreateTitleChange: workspace.setIssueTitle,
onManageProjects: () => navigate("/dashboard"),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
),
onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer");
},
onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues">
);
},
onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}
if (screen === "work-unit-detail") {
return <MobileWorkUnitDetailScreen {...screenProps} />;
}
if (workspace.expanded) {
return <MobileExpandedWorkScreen {...screenProps} />;
}
return <MobileWorkListScreen {...screenProps} />;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
import { useFlueAgent } from "@flue/react"; import { useFlueAgent } from "@flue/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization"; import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants"; import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types"; import type { ChatAgentState } from "@/lib/chat/types";
export const useChatAgent = (): ChatAgentState => { export const useOrganizationChatAgent = (
// The agent session is not created until the authenticated user's personal organization: PersonalOrganizationState
// organization has been ensured and its id is available. ): ChatAgentState => {
const organization = usePersonalOrganization();
const agent = useFlueAgent({ const agent = useFlueAgent({
...CHAT_AGENT, ...CHAT_AGENT,
id: organization.organizationId, id: organization.organizationId,
@@ -36,3 +36,6 @@ export const useChatAgent = (): ChatAgentState => {
status, status,
}; };
}; };
export const useChatAgent = (): ChatAgentState =>
useOrganizationChatAgent(usePersonalOrganization());

View File

@@ -0,0 +1,91 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type {
MobileAssistantMessageView,
MobileAssistantView,
} from "@code/ui/components/mobile-product";
import { useQuery } from "convex/react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
): MobileAssistantMessageView[] =>
messages.flatMap((message) => {
if (message.role !== "assistant" && message.role !== "user") {
return [];
}
const text = getMessageText(message);
return text
? [
{
id: message.id,
role: message.role,
text,
},
]
: [];
});
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
): MobileAssistantView["statusTone"] => {
if (status === "error") {
return "red";
}
return status === "connecting" ? "amber" : "green";
};
interface UseMobileProjectWorkspaceOptions {
readonly selectedWorkUnitId?: string;
}
export const useMobileProjectWorkspace = ({
selectedWorkUnitId,
}: UseMobileProjectWorkspaceOptions) => {
const organization = usePersonalOrganization();
const projectWorkspace = useProjectWorkspace();
const chatAgent = useOrganizationChatAgent(organization);
const signals = useQuery(
api.signals.list,
organization.organizationId
? { organizationId: organization.organizationId }
: "skip"
);
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages),
statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status),
};
return {
data: buildMobileWorkspaceView({
artifacts: projectWorkspace.artifacts,
assistant,
events: projectWorkspace.events,
issues: projectWorkspace.issues,
organizationLabel: organization.organizationName,
projects: projectWorkspace.projects,
selectedProject: projectWorkspace.selectedProject,
selectedWorkUnitId,
signals,
}),
error: organization.error ?? chatAgent.error,
organization,
projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage: chatAgent.sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit,
} as const;
};

View File

@@ -0,0 +1,82 @@
import type { FormEvent } from "react";
import { useState } from "react";
interface UseMobileWorkspaceOptions {
readonly initialExpanded?: boolean;
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
readonly onSend: (message: string) => Promise<void>;
}
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Message could not be sent";
export const useMobileWorkspace = ({
initialExpanded = false,
onCreateIssue,
onSend,
}: UseMobileWorkspaceOptions) => {
const [composerValue, setComposerValue] = useState("");
const [issueBody, setIssueBody] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
setComposerValue(value);
setStatusMessage(undefined);
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = composerValue.trim();
if (!message) {
return;
}
setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try {
await onSend(message);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void sendMessage();
};
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const title = issueTitle.trim();
const body = issueBody.trim();
if (!title || !body) {
return;
}
setStatusMessage("Creating project work…");
const createIssue = async () => {
try {
await onCreateIssue(title, body);
setIssueTitle("");
setIssueBody("");
setStatusMessage("Project work created");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void createIssue();
};
return {
composerValue,
expanded,
handleComposerChange,
handleComposerSubmit,
handleCreateIssueSubmit,
issueBody,
issueTitle,
setExpanded,
setIssueBody,
setIssueTitle,
statusMessage,
};
};

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
export interface PersonalOrganizationState { export interface PersonalOrganizationState {
readonly error?: Error; readonly error?: Error;
readonly organizationId?: Id<"organizations">; readonly organizationId?: Id<"organizations">;
readonly organizationName?: string;
} }
/** Ensure the authenticated user has its personal tenancy boundary. */ /** Ensure the authenticated user has its personal tenancy boundary. */
@@ -16,10 +17,12 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
const ensurePersonalOrganization = useMutation( const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization api.organizations.ensurePersonalOrganization
); );
const [organizationId, setOrganizationId] = useState< const [state, setState] = useState<{
Id<"organizations"> | undefined readonly error?: Error;
>(); readonly organizationId?: Id<"organizations">;
const [error, setError] = useState<Error | undefined>(); readonly organizationName?: string;
readonly userId: string;
}>();
useEffect(() => { useEffect(() => {
if (!userId) { if (!userId) {
@@ -31,16 +34,21 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try { try {
const organization = await ensurePersonalOrganization(); const organization = await ensurePersonalOrganization();
if (active) { if (active) {
setOrganizationId(organization._id); setState({
setError(undefined); organizationId: organization._id,
organizationName: organization.name,
userId,
});
} }
} catch (caughtError: unknown) { } catch (caughtError: unknown) {
if (active) { if (active) {
setError( setState({
caughtError instanceof Error error:
? caughtError caughtError instanceof Error
: new Error(String(caughtError)) ? caughtError
); : new Error(String(caughtError)),
userId,
});
} }
} }
}; };
@@ -51,9 +59,13 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
}; };
}, [ensurePersonalOrganization, userId]); }, [ensurePersonalOrganization, userId]);
if (!userId) { if (!userId || state?.userId !== userId) {
return {}; return {};
} }
return { error, organizationId }; return {
error: state.error,
organizationId: state.organizationId,
organizationName: state.organizationName,
};
}; };

View File

@@ -4,6 +4,11 @@ import { useFlueClient } from "@flue/react";
import { useAction, useMutation, useQuery } from "convex/react"; import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react"; import { useState } from "react";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "@/lib/projects/project-evidence";
const errorMessage = (error: unknown) => const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
@@ -15,10 +20,13 @@ export const useProjectWorkspace = () => {
const [repository, setRepository] = useState(""); const [repository, setRepository] = useState("");
const [issueTitle, setIssueTitle] = useState(""); const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState(""); const [issueBody, setIssueBody] = useState("");
const [selectedIssueId, setSelectedIssueId] =
useState<Id<"projectIssues"> | null>(null);
const [pendingAction, setPendingAction] = useState<string | null>(null); const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const importPublicGit = useAction(api.projects.importPublicGit); const importPublicGit = useAction(api.projects.importPublicGit);
const createIssue = useMutation(api.projectIssues.create); const createIssue = useMutation(api.projectIssues.create);
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
const beginIssue = useMutation(api.projectIssues.begin); const beginIssue = useMutation(api.projectIssues.begin);
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed); const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
const activeProjectId = const activeProjectId =
@@ -33,6 +41,30 @@ export const useProjectWorkspace = () => {
api.projectIssues.list, api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip" activeProjectId ? { projectId: activeProjectId } : "skip"
); );
const events = useQuery(
api.projectIssues.events,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const pullRequest = events
?.map((event) => event.data)
.find(
(
data
): data is {
pullRequest: {
baseBranch: string;
branch: string;
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof data === "object" &&
data !== null &&
"pullRequest" in data &&
typeof data.pullRequest === "object" &&
data.pullRequest !== null
)?.pullRequest;
const connectRepository = async () => { const connectRepository = async () => {
setPendingAction("connect"); setPendingAction("connect");
@@ -48,18 +80,24 @@ export const useProjectWorkspace = () => {
} }
}; };
const raiseIssue = async () => { const raiseIssue = async (input?: {
readonly body?: string;
readonly title?: string;
}) => {
if (!activeProjectId) { if (!activeProjectId) {
return; return;
} }
const nextTitle = input?.title ?? issueTitle;
const nextBody = input?.body ?? issueBody;
setPendingAction("issue"); setPendingAction("issue");
setError(null); setError(null);
try { try {
await createIssue({ const issueId = await createIssue({
body: issueBody, body: nextBody,
projectId: activeProjectId, projectId: activeProjectId,
title: issueTitle, title: nextTitle,
}); });
setSelectedIssueId(issueId);
setIssueTitle(""); setIssueTitle("");
setIssueBody(""); setIssueBody("");
} catch (caughtError) { } catch (caughtError) {
@@ -69,6 +107,19 @@ export const useProjectWorkspace = () => {
} }
}; };
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
setPendingAction(`signal:${signalId}`);
setError(null);
try {
const outcome = await createIssueFromSignal({ signalId });
setSelectedIssueId(outcome.issueId);
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const startIssue = async ( const startIssue = async (
issueId: Id<"projectIssues">, issueId: Id<"projectIssues">,
issueNumber: number, issueNumber: number,
@@ -90,28 +141,54 @@ export const useProjectWorkspace = () => {
setPendingAction(null); setPendingAction(null);
} }
}; };
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
const issue = issues?.find((candidate) => candidate._id === issueId);
if (!issue) {
setError("That work unit is no longer available");
return;
}
await startIssue(issue._id, issue.number, issue.title);
};
const selectedProject = const selectedProject =
projects?.find( projects?.find(
(project) => project.id === (activeProjectId as unknown as string) (project) => project.id === (activeProjectId as unknown as string)
) ?? null; ) ?? null;
const selectedIssue =
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
const projectLoop = buildProjectLoopView({
artifacts,
issue: selectedIssue,
source: selectedProject?.sources[0],
});
const issueSummary = summarizeProjectIssues(issues);
return { return {
artifacts, artifacts,
connectRepository, connectRepository,
error, error,
events,
issueBody, issueBody,
issueSummary,
issueTitle, issueTitle,
issues, issues,
pendingAction, pendingAction,
projectLoop,
projects, projects,
pullRequest,
raiseIssue, raiseIssue,
raiseIssueFromSignal,
repository, repository,
selectedIssue,
selectedIssueId,
selectedProject, selectedProject,
selectedProjectId: activeProjectId, selectedProjectId: activeProjectId,
setIssueBody, setIssueBody,
setIssueTitle, setIssueTitle,
setRepository, setRepository,
setSelectedIssueId,
setSelectedProjectId, setSelectedProjectId,
startIssue, startIssue,
startWorkUnit,
} as const; } as const;
}; };

View File

@@ -4,49 +4,10 @@
html, html,
body { body {
min-height: 100%; min-height: 100%;
overflow: hidden;
} }
.chat-surface { .ai-conversation-mobile > div {
position: relative; scrollbar-gutter: auto !important;
isolation: isolate;
background:
radial-gradient(
circle at 50% -12%,
oklch(0.72 0.045 255 / 9%),
transparent 32rem
),
var(--background);
}
.chat-surface::before {
position: absolute;
z-index: -1;
inset: 0;
background-image: radial-gradient(
oklch(0.5 0 0 / 9%) 0.55px,
transparent 0.55px
);
background-size: 18px 18px;
mask-image: linear-gradient(to bottom, black, transparent 42%);
content: "";
pointer-events: none;
}
.chat-header {
background: color-mix(in oklch, var(--background) 88%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
}
.zopu-mark {
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
background:
linear-gradient(145deg, oklch(1 0 0 / 65%), transparent 55%),
color-mix(in oklch, var(--muted) 88%, var(--background));
color: var(--foreground);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 48%),
0 6px 18px -12px oklch(0 0 0 / 55%);
} }
.chat-message { .chat-message {
@@ -120,15 +81,6 @@ body {
} }
} }
.composer-dock {
position: relative;
background: linear-gradient(to top, var(--background) 68%, transparent);
}
.composer-shell {
transform: translateZ(0);
}
@keyframes chat-message-enter { @keyframes chat-message-enter {
from { from {
opacity: 0; opacity: 0;

View File

@@ -0,0 +1,186 @@
import type { api } from "@code/backend/convex/_generated/api";
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type {
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "@code/ui/components/mobile-product";
import type { FunctionReturnType } from "convex/server";
type SignalList = FunctionReturnType<typeof api.signals.list>;
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
interface BuildMobileWorkspaceViewInput {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
readonly assistant: MobileAssistantView;
readonly events: ProjectEventList | undefined;
readonly issues: readonly Doc<"projectIssues">[] | undefined;
readonly organizationLabel?: string;
readonly projects: readonly ProjectView[] | undefined;
readonly selectedProject: ProjectView | null;
readonly selectedWorkUnitId?: string;
readonly signals: SignalList | undefined;
}
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
});
const statusTone = (
status: ReturnType<typeof getProjectWorkStatus>
): MobileWorkUnitTone => {
if (status.phase === "completed") {
return "green";
}
if (status.phase === "failed" || status.phase === "waiting") {
return "orange";
}
if (status.phase === "captured") {
return "purple";
}
return "blue";
};
const isPullRequestData = (
value: unknown
): value is {
pullRequest: {
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof value === "object" &&
value !== null &&
"pullRequest" in value &&
typeof value.pullRequest === "object" &&
value.pullRequest !== null &&
"number" in value.pullRequest &&
typeof value.pullRequest.number === "number" &&
"url" in value.pullRequest &&
typeof value.pullRequest.url === "string";
const toWorkUnit = (
issue: Doc<"projectIssues">,
artifactCount: number,
events: ProjectEventList | undefined
): MobileWorkUnitView => {
const status = getProjectWorkStatus(issue.status);
const pullRequest = events
?.filter((event) => event.issueId === issue._id)
.map((event) => event.data)
.find(isPullRequestData)?.pullRequest;
return {
artifactCount,
canRetry: status.phase === "failed" || status.phase === "waiting",
canStart: status.phase === "captured",
code: `#${issue.number}`,
id: issue._id,
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequestNumber: pullRequest?.number,
reviewUrl: pullRequest?.url,
statusLabel: status.label,
summary: issue.body,
title: issue.title,
tone: statusTone(status),
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
};
};
const toLatestSignalView = (
signal: SignalList[number]["signal"] | undefined
): MobileSignalView | undefined => {
if (!signal) {
return;
}
return {
desiredOutcome: signal.problemStatement.desiredOutcome,
id: signal._id,
projectId: signal.projectId ?? undefined,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
};
};
const relevantSignalsForProject = (
signals: SignalList | undefined,
projectId: ProjectView["id"] | undefined
) =>
signals?.filter(
({ signal }) =>
!signal.projectId || String(signal.projectId) === String(projectId)
) ?? [];
const makeProjectViews = (
projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] =>
projects?.map((project) => ({
connected: project.sources.length > 0,
id: project.id,
name: project.name,
})) ?? [];
export const buildMobileWorkspaceView = ({
artifacts,
assistant,
events,
issues,
organizationLabel = "Personal workspace",
projects,
selectedProject,
selectedWorkUnitId,
signals,
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
const artifactCount = artifacts?.length ?? 0;
const workUnits =
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
const selectedWorkUnit =
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
workUnits.find((workUnit) => workUnit.tone === "blue") ??
workUnits[0];
const selectedProjectId = selectedProject?.id;
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
const latestSignal = relevantSignals[0]?.signal;
const activeCount = workUnits.filter(
(workUnit) => workUnit.progress < 100
).length;
const needsAttentionCount = issues?.filter(
(issue) => issue.status === "failed" || issue.status === "needs-input"
).length;
const shippedCount = issues?.filter(
(issue) => issue.status === "completed"
).length;
const hasSelectedProject = Boolean(selectedProject);
const projectViews = makeProjectViews(projects);
return {
activeCount,
activityCount: workUnits.length + relevantSignals.length,
artifactCount,
assistant,
isLoading:
projects === undefined ||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
latestSignal: toLatestSignalView(latestSignal),
needsAttentionCount: needsAttentionCount ?? 0,
organizationLabel,
projectName: selectedProject?.name,
projects: projectViews,
selectedProjectId: selectedProject?.id,
selectedWorkUnit,
shippedCount: shippedCount ?? 0,
totalCount: workUnits.length,
workUnits,
};
};

View File

@@ -0,0 +1,91 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "./project-evidence";
const makeIssue = (
status: Doc<"projectIssues">["status"],
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
): Doc<"projectIssues"> =>
({
_creationTime: 1,
_id: issueId,
body: "Make the project loop visible in the web app.",
createdAt: 1000,
number: 8,
projectId: "project-1",
status,
title: "Build project loop UI",
updatedAt: 2000,
}) as Doc<"projectIssues">;
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
({
_creationTime: 1,
_id: "artifact-1",
content,
createdAt: 1000,
path: "artifacts.md",
projectId: "project-1",
revision: 2,
updatedAt: 2000,
}) as Doc<"projectArtifacts">;
describe("project evidence", () => {
test("turns published PR evidence into a review action", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact(
"Verification: 14 unit tests passed.\nPull request: PR #42"
),
],
issue: makeIssue("completed"),
source: {
host: "git.example.com",
repositoryPath: "puter/zopu",
url: "https://git.example.com/puter/zopu",
},
});
expect(view?.progress).toBe(100);
expect(view?.verification).toBe("passed");
expect(view?.pullRequest.reviewUrl).toBe(
"https://git.example.com/puter/zopu/pulls/42"
);
expect(view?.verificationNote).toContain(
"Verification: 14 unit tests passed."
);
});
test("keeps the review slot pending when no PR is published", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact("The run is recording implementation evidence."),
],
issue: makeIssue("working"),
source: undefined,
});
expect(view?.verification).toBe("running");
expect(view?.pullRequest.state).toBe("pending");
expect(view?.pullRequest.reviewUrl).toBeUndefined();
});
test("summarizes the project queue without inventing work", () => {
const summary = summarizeProjectIssues([
makeIssue("working"),
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
makeIssue("completed", "issue-3" as Id<"projectIssues">),
]);
expect(summary).toEqual({
active: 2,
completed: 1,
needsInput: 1,
total: 3,
});
});
});

View File

@@ -0,0 +1,315 @@
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type { ProjectVerificationStatus } from "@code/primitives/project-work";
type ProjectIssue = Doc<"projectIssues">;
type ProjectArtifact = Doc<"projectArtifacts">;
export interface ProjectSourceSummary {
readonly host: string;
readonly repositoryPath: string;
readonly url: string;
}
export interface ProjectIssueSummary {
readonly active: number;
readonly completed: number;
readonly needsInput: number;
readonly total: number;
}
export interface ProjectVerificationCheck {
readonly detail: string;
readonly label: string;
readonly state: "attention" | "complete" | "current" | "upcoming";
}
export interface ProjectActivityItem {
readonly detail: string;
readonly label: string;
readonly time: string;
}
export interface ProjectPullRequest {
readonly number: number | undefined;
readonly reviewUrl: string | undefined;
readonly state: "available" | "pending";
}
export interface ProjectLoopView {
readonly activity: readonly ProjectActivityItem[];
readonly currentState: string;
readonly nextAction: string;
readonly progress: number;
readonly pullRequest: ProjectPullRequest;
readonly status: ReturnType<typeof getProjectWorkStatus>;
readonly verification: ProjectVerificationStatus;
readonly verificationChecks: readonly ProjectVerificationCheck[];
readonly verificationNote: string;
}
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
completed:
"Implementation and verification are complete. Review the resulting change.",
failed:
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
"needs-input":
"The run is paused until a project decision or missing piece is resolved.",
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
queued: "The issue is queued for the project manager to pick up.",
working:
"The project manager is working through the issue and recording durable evidence.",
};
const formatTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
const relativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
artifacts.map((artifact) => artifact.content).join("\n");
const extractPullRequest = (
artifacts: readonly ProjectArtifact[],
source: ProjectSourceSummary | undefined
): ProjectPullRequest => {
const text = allArtifactText(artifacts);
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
const numberValue = numberMatch?.groups?.number;
const number = numberValue ? Number(numberValue) : undefined;
let reviewUrl = urlMatch;
if (!reviewUrl && number && source) {
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
}
return {
number,
reviewUrl,
state: reviewUrl ? "available" : "pending",
};
};
const extractVerificationNote = (
artifacts: readonly ProjectArtifact[],
status: ProjectIssueStatus
): string => {
const evidenceLine = allArtifactText(artifacts)
.split("\n")
.map((line) => line.trim())
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
if (evidenceLine) {
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
}
if (status === "completed") {
return "The agent reported completion after its verification step.";
}
if (status === "failed") {
return "Verification did not complete successfully.";
}
if (status === "working") {
return "Verification will appear here as the run records evidence.";
}
return "No verification evidence has been recorded yet.";
};
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
if (status === "queued") {
return {
detail: "Agent admission is queued",
label: "Agent run",
state: "current",
};
}
if (status === "open") {
return {
detail: "Start the issue to create a run",
label: "Agent run",
state: "upcoming",
};
}
return {
detail: "Project manager run is attached",
label: "Agent run",
state: "complete",
};
};
const makeVerificationCheck = (
verification: ProjectVerificationStatus
): ProjectVerificationCheck => {
if (verification === "passed") {
return {
detail: "Verification passed",
label: "Verification",
state: "complete",
};
}
if (verification === "failed") {
return {
detail: "Review the preserved failure",
label: "Verification",
state: "attention",
};
}
if (verification === "blocked") {
return {
detail: "Waiting on a project decision",
label: "Verification",
state: "attention",
};
}
if (verification === "running") {
return {
detail: "Evidence is being recorded",
label: "Verification",
state: "current",
};
}
return {
detail: "No verification result yet",
label: "Verification",
state: "upcoming",
};
};
const makeChecks = (
status: ProjectIssueStatus,
verification: ProjectVerificationStatus,
pullRequest: ProjectPullRequest
): readonly ProjectVerificationCheck[] => {
const issueCheck: ProjectVerificationCheck =
status === "open"
? {
detail: "Waiting to be started",
label: "Issue accepted",
state: "upcoming",
}
: {
detail: "Issue context is attached",
label: "Issue accepted",
state: "complete",
};
const pullRequestCheck: ProjectVerificationCheck =
pullRequest.state === "available"
? {
detail: "Ready for human review",
label: "Gitea pull request",
state: "complete",
}
: {
detail: "Appears after a verified run publishes one",
label: "Gitea pull request",
state: "upcoming",
};
return [
issueCheck,
makeRunCheck(status),
makeVerificationCheck(verification),
pullRequestCheck,
];
};
const buildActivity = (
issue: ProjectIssue,
artifacts: readonly ProjectArtifact[],
statusLabel: string
): readonly ProjectActivityItem[] => {
const artifact = [...artifacts]
.toSorted((left, right) => right.updatedAt - left.updatedAt)
.find((candidate) => candidate.path !== "work.md");
const items: ProjectActivityItem[] = [
{
detail: `Issue #${issue.number} entered the project loop`,
label: "Issue created",
time: formatTime(issue.createdAt),
},
];
if (issue.updatedAt !== issue.createdAt) {
items.unshift({
detail: `Status is now ${statusLabel.toLowerCase()}`,
label: "Work state updated",
time: relativeTime(issue.updatedAt),
});
}
if (artifact) {
items.unshift({
detail: `Durable project evidence changed in ${artifact.path}`,
label: "Evidence recorded",
time: relativeTime(artifact.updatedAt),
});
}
return items.slice(0, 3);
};
export const summarizeProjectIssues = (
issues: readonly ProjectIssue[] | undefined
): ProjectIssueSummary => {
if (!issues) {
return { active: 0, completed: 0, needsInput: 0, total: 0 };
}
return {
active: issues.filter(
(issue) => !["completed", "failed"].includes(issue.status)
).length,
completed: issues.filter((issue) => issue.status === "completed").length,
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
total: issues.length,
};
};
export const buildProjectLoopView = ({
artifacts,
issue,
source,
}: {
readonly artifacts: readonly ProjectArtifact[] | undefined;
readonly issue: ProjectIssue | undefined;
readonly source: ProjectSourceSummary | undefined;
}): ProjectLoopView | null => {
if (!issue) {
return null;
}
const status = getProjectWorkStatus(issue.status);
const { verification } = status;
const resolvedArtifacts = artifacts ?? [];
const pullRequest = extractPullRequest(resolvedArtifacts, source);
return {
activity: buildActivity(issue, resolvedArtifacts, status.label),
currentState: STATUS_TEXT[issue.status],
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequest,
status,
verification,
verificationChecks: makeChecks(issue.status, verification, pullRequest),
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
};
};

View File

@@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
<body> <body className="bg-[#11110f]">
{children} {children}
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
@@ -81,13 +81,12 @@ const App = () => (
<AuthenticatedFlueProvider> <AuthenticatedFlueProvider>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="dark" defaultTheme="light"
forcedTheme="light"
disableTransitionOnChange disableTransitionOnChange
storageKey="vite-ui-theme" storageKey="vite-ui-theme"
> >
<div className="h-svh min-h-0 overflow-hidden"> <Outlet />
<Outlet />
</div>
<Toaster richColors /> <Toaster richColors />
</ThemeProvider> </ThemeProvider>
</AuthenticatedFlueProvider> </AuthenticatedFlueProvider>

View File

@@ -1,4 +1,19 @@
import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig; export default [
layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,12 +0,0 @@
import { ChatPage } from "@/components/chat/chat-page";
import type { Route } from "./+types/_app._index";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
const Home = () => <ChatPage />;
export default Home;

View File

@@ -1,102 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
return (
<div className="w-full mx-auto max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTodo} className="mb-6 flex items-center space-x-2">
<Input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
/>
<Button type="submit" disabled={!newTodoText.trim()}>
Add
</Button>
</form>
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() => handleToggleTodo(todo._id, todo.completed)}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />;
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Active work | Zopu" },
{
content: "Browse active work and expand a work unit in place",
name: "description",
},
];
export default function MobileWorkListPage() {
return <MobileFlowPage screen="work-list" />;
}

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work unit | Zopu" },
{
content: "Review the state, next milestone, and activity for a work unit",
name: "description",
},
];
export default function MobileWorkUnitPage() {
return <MobileFlowPage screen="work-unit-detail" />;
}

View File

@@ -0,0 +1,123 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = newTodoText.trim();
if (!text) {
return;
}
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
void toggleTodo({ completed: !currentCompleted, id });
};
const handleDeleteTodo = (id: Id<"todos">) => {
void deleteTodo({ id });
};
let todoContent: ReactNode;
if (todos === undefined) {
todoContent = (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
} else if (todos.length === 0) {
todoContent = (
<p className="py-4 text-center">No todos yet. Add one above!</p>
);
} else {
todoContent = (
<ul className="space-y-2">
{todos.map((todo) => (
<li
className="flex items-center justify-between rounded-md border p-2"
key={todo._id}
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
id={`todo-${todo._id}`}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
/>
<label
className={
todo.completed
? "text-muted-foreground line-through"
: undefined
}
htmlFor={`todo-${todo._id}`}
>
{todo.text}
</label>
</div>
<Button
aria-label="Delete todo"
onClick={() => handleDeleteTodo(todo._id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
);
}
return (
<div className="mx-auto w-full max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form
className="mb-6 flex items-center space-x-2"
onSubmit={handleAddTodo}
>
<Input
onChange={(event) => setNewTodoText(event.target.value)}
placeholder="Add a new task..."
value={newTodoText}
/>
<Button disabled={!newTodoText.trim()} type="submit">
Add
</Button>
</form>
{todoContent}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { LoginForm } from "@code/auth/web"; import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.login"; import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [ export const meta = (_args: Route.MetaArgs) => [
{ title: "Sign in | Zopu" }, { title: "Sign in | Zopu" },

View File

@@ -1,7 +1,7 @@
import { SignupForm } from "@code/auth/web"; import { SignupForm } from "@code/auth/web";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Route } from "./+types/_auth.signup"; import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [ export const meta = (_args: Route.MetaArgs) => [
{ title: "Create account | Zopu" }, { title: "Create account | Zopu" },

View File

@@ -1,5 +1,10 @@
{ {
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], "include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2024"], "lib": ["DOM", "DOM.Iterable", "ES2024"],
"types": ["node", "vite/client"], "types": ["node", "vite/client"],
@@ -10,6 +15,8 @@
"rootDirs": [".", "./.react-router/types"], "rootDirs": [".", "./.react-router/types"],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@code/primitives": ["../../packages/primitives/src/index.ts"],
"@code/primitives/*": ["../../packages/primitives/src/*"],
"@code/ui/*": ["../../packages/ui/src/*"] "@code/ui/*": ["../../packages/ui/src/*"]
}, },
"esModuleInterop": true, "esModuleInterop": true,

View File

@@ -3,12 +3,12 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite"; import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus"; import { defineConfig } from "vite-plus";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."), envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], plugins: [tailwindcss(), reactRouter()],
resolve: { resolve: {
dedupe: ["convex", "react", "react-dom"], dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
}, },
}); });

162
bun.lock
View File

@@ -5,10 +5,16 @@
"": { "": {
"name": "code", "name": "code",
"devDependencies": { "devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"convex": "catalog:",
"convex-test": "catalog:", "convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "latest", "oxfmt": "latest",
"oxlint": "latest", "oxlint": "latest",
"rolldown": "1.1.4", "rolldown": "1.1.4",
@@ -112,6 +118,7 @@
"@code/auth": "workspace:*", "@code/auth": "workspace:*",
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*", "@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9", "@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9",
@@ -149,10 +156,12 @@
"dependencies": { "dependencies": {
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"hono": "4.12.30", "effect": "catalog:",
"hono": "4.12.31",
"valibot": "^1.4.2", "valibot": "^1.4.2",
}, },
"devDependencies": { "devDependencies": {
@@ -232,6 +241,7 @@
"name": "@code/primitives", "name": "@code/primitives",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -248,6 +258,11 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.0", "@shadcn/react": "^0.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"ai": "^7.0.35",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "catalog:", "lucide-react": "catalog:",
@@ -255,9 +270,12 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"shadcn": "^4.12.0", "shadcn": "^4.12.0",
"shiki": "^4.3.1",
"sonner": "catalog:", "sonner": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "catalog:", "tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6",
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
@@ -316,6 +334,8 @@
"@agentos-software/gawk": ["@agentos-software/gawk@0.3.4", "", {}, "sha512-NlU6nGxoqIUc1I2zdBHFwH04gE0tBygP2FcBO12VBptty7lbgq1CNLk909jIcSNFEwlm3VRPHeZNkbdVKZ2Ujg=="], "@agentos-software/gawk": ["@agentos-software/gawk@0.3.4", "", {}, "sha512-NlU6nGxoqIUc1I2zdBHFwH04gE0tBygP2FcBO12VBptty7lbgq1CNLk909jIcSNFEwlm3VRPHeZNkbdVKZ2Ujg=="],
"@agentos-software/git": ["@agentos-software/git@0.3.3", "", {}, "sha512-6hCVv4P9eZ5JiPUcLv7y2I5rgxgKvXfdNRvyXCHecMyz6oxE5T0VW8WkSMuwmGTQFUhIIvPn7DpyNDbCaGMpgw=="],
"@agentos-software/grep": ["@agentos-software/grep@0.3.4", "", {}, "sha512-Bta2Ljl+kCX/3Bjg06Q9N9LPRcf13S92lHRaYG+CeGVDXabIIgkHLUGIQlI1OKuIr7IRAktjgelUAuJnxGFvvw=="], "@agentos-software/grep": ["@agentos-software/grep@0.3.4", "", {}, "sha512-Bta2Ljl+kCX/3Bjg06Q9N9LPRcf13S92lHRaYG+CeGVDXabIIgkHLUGIQlI1OKuIr7IRAktjgelUAuJnxGFvvw=="],
"@agentos-software/gzip": ["@agentos-software/gzip@0.3.4", "", {}, "sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg=="], "@agentos-software/gzip": ["@agentos-software/gzip@0.3.4", "", {}, "sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg=="],
@@ -330,6 +350,12 @@
"@agentos-software/tar": ["@agentos-software/tar@0.3.4", "", {}, "sha512-/SSqfgS5xOufvulsz5kVDTCFxpDy+5s7Og1iJe9krjU7Jvtgnp9TpPaJnSr721h6uY6tsWHcs3u8E457EwSkNw=="], "@agentos-software/tar": ["@agentos-software/tar@0.3.4", "", {}, "sha512-/SSqfgS5xOufvulsz5kVDTCFxpDy+5s7Og1iJe9krjU7Jvtgnp9TpPaJnSr721h6uY6tsWHcs3u8E457EwSkNw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gqTMvV0N8/JirIZ3OzwjSZRYxzwZu/PeOFCKb8NB9fstWH39tI+L6CkeMNVou5/HCKEYAw6RCOHW59Vhquv8vA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.12", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
@@ -1346,6 +1372,22 @@
"@shadcn/react": ["@shadcn/react@0.2.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="], "@shadcn/react": ["@shadcn/react@0.2.1", "", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="],
"@shikijs/core": ["@shikijs/core@4.3.1", "", { "dependencies": { "@shikijs/primitive": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg=="],
"@shikijs/langs": ["@shikijs/langs@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A=="],
"@shikijs/themes": ["@shikijs/themes@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA=="],
"@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="],
@@ -1392,6 +1434,14 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@streamdown/cjk": ["@streamdown/cjk@1.0.3", "", { "dependencies": { "remark-cjk-friendly": "^2.0.1", "remark-cjk-friendly-gfm-strikethrough": "^2.0.1", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw=="],
"@streamdown/code": ["@streamdown/code@1.1.1", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg=="],
"@streamdown/math": ["@streamdown/math@1.0.2", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g=="],
"@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="],
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="], "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@@ -1560,6 +1610,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
@@ -1618,6 +1670,8 @@
"@vercel/detect-agent": ["@vercel/detect-agent@1.2.3", "", {}, "sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag=="], "@vercel/detect-agent": ["@vercel/detect-agent@1.2.3", "", {}, "sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitest/browser": ["@vitest/browser@4.1.9", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="], "@vitest/browser": ["@vitest/browser@4.1.9", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="],
"@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="], "@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="],
@@ -1654,6 +1708,8 @@
"@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+VUui1OIaFX0tqdUAXjmoKVlujEtWVdcsFDw2Jff+D6b4LUTQaOMAaaic8nNdfZL6wEjweRREQLZi/icZAXtNQ=="], "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+VUui1OIaFX0tqdUAXjmoKVlujEtWVdcsFDw2Jff+D6b4LUTQaOMAaaic8nNdfZL6wEjweRREQLZi/icZAXtNQ=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
@@ -1670,6 +1726,8 @@
"agent-cli-detector": ["agent-cli-detector@0.1.4", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q=="], "agent-cli-detector": ["agent-cli-detector@0.1.4", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q=="],
"ai": ["ai@7.0.36", "", { "dependencies": { "@ai-sdk/gateway": "4.0.27", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1XJjua58GVQ0CyO2Xbioyladt85x71Joup2U8qKrjHUl8tHYwrDw8iFRtav6e94AxSVCn9FVgTS17oX1OCquKA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -2016,7 +2074,7 @@
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
@@ -2156,7 +2214,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -2436,18 +2494,30 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
@@ -2466,7 +2536,7 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="], "hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="],
@@ -2616,6 +2686,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -2722,7 +2794,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -2746,6 +2818,8 @@
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
@@ -2758,6 +2832,10 @@
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-markdown-cjk-friendly": ["mdast-util-to-markdown-cjk-friendly@1.0.0", "", { "dependencies": { "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "@types/mdast": "*" }, "optionalPeers": ["@types/mdast"] }, "sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw=="],
"mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": ["mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0", "", { "dependencies": { "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "@types/mdast": "*" }, "optionalPeers": ["@types/mdast"] }, "sha512-1ePVfB4P/vz3xSsm6H3D32r6VYGErxclnuLLFK02/2ReF+UdEKm7caulK6Vm0LBIp5gPRtB2Z1OYDznCkX3k2w=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
@@ -2808,6 +2886,12 @@
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="],
"micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.4.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg=="],
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark-util-types": "*" }, "optionalPeers": ["micromark-util-types"] }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
@@ -2822,6 +2906,8 @@
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
@@ -2992,6 +3078,10 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
@@ -3266,6 +3356,12 @@
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="],
"regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
@@ -3274,12 +3370,20 @@
"rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-cjk-friendly": ["remark-cjk-friendly@2.3.1", "", { "dependencies": { "mdast-util-to-markdown-cjk-friendly": "1.0.0", "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA=="],
"remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@2.3.1", "", { "dependencies": { "mdast-util-to-markdown-cjk-friendly-gfm-strikethrough": "1.0.0", "micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
@@ -3384,6 +3488,8 @@
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -3632,10 +3738,14 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
@@ -3662,6 +3772,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.6", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
@@ -3854,8 +3966,6 @@
"@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@flue/runtime/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@google/genai/google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], "@google/genai/google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="],
"@google/genai/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "@google/genai/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
@@ -3888,10 +3998,10 @@
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], "@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
@@ -3914,6 +4024,8 @@
"@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"@streamdown/code/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
@@ -4000,24 +4112,24 @@
"d3/d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], "d3/d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3/d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3/d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3/d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-chord/d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"diffie-hellman/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], "diffie-hellman/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"elliptic/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], "elliptic/bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
@@ -4132,8 +4244,6 @@
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
@@ -4182,8 +4292,6 @@
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], "ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
"rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="], "rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
@@ -4208,8 +4316,6 @@
"stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -4376,8 +4482,6 @@
"@rivet-dev/agentos/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="], "@rivet-dev/agentos/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivet-dev/agentos/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivet-dev/agentos/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="], "@rivet-dev/agentos/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="], "@rivetkit/framework-base/rivetkit/@rivetkit/engine-cli": ["@rivetkit/engine-cli@2.3.7", "", { "optionalDependencies": { "@rivetkit/engine-cli-darwin-arm64": "2.3.7", "@rivetkit/engine-cli-darwin-x64": "2.3.7", "@rivetkit/engine-cli-linux-arm64-musl": "2.3.7", "@rivetkit/engine-cli-linux-x64-musl": "2.3.7", "@rivetkit/engine-cli-win32-x64": "2.3.7" } }, "sha512-CezLwJ0B7dWDbA7qM6Aq04mwnrJAdrDActRrrcb4NBa20h7wO9KPAYBwyJz/dRnKm9EUUNcFZ6hrSDpp6T3+Rg=="],
@@ -4394,8 +4498,6 @@
"@rivetkit/framework-base/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="], "@rivetkit/framework-base/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/framework-base/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivetkit/framework-base/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="], "@rivetkit/framework-base/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@rivetkit/react/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], "@rivetkit/react/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
@@ -4414,8 +4516,6 @@
"@rivetkit/react/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="], "@rivetkit/react/rivetkit/@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.3.7", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-+C4kGuSNysrw2zs/LQoYxouOxufKHHMxiIXKF8Ab9GU2BCJ32RUST9dYU/gaQE/w9uM+hqAHUz0DTlOBYU6p6g=="],
"@rivetkit/react/rivetkit/hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"@rivetkit/react/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="], "@rivetkit/react/rivetkit/uuid": ["uuid@12.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
@@ -4470,6 +4570,18 @@
"@secure-exec/nodejs/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], "@secure-exec/nodejs/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@streamdown/code/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@streamdown/code/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
"@streamdown/code/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@streamdown/code/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@streamdown/code/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@streamdown/code/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

46
docs/SMOKE.md Normal file
View File

@@ -0,0 +1,46 @@
# Zopu Integration Smoke
The smoke harness is a repeatable integration lane for the thin Zopu MVP. It probes the local web app, authenticated Convex control plane, Flue `project-manager` route, AgentOS daemon/workspace contract, and the CPA OpenAI-compatible gateway before it creates any project issue.
The harness does not edit CPA configuration, restart services, print bearer tokens/API keys, or run parallel worker calls. `minimax-m3` is the serial worker-loop model; `glm-5.2` performs the plan and the independent review.
## Setup
Use a disposable/test project that already has a connected repository source and the issue-scoped artifact set. Export a Better Auth/Convex access token for the signed-in user, the local daemon id, and the CPA key/base URL. The CPA URL must include its OpenAI-compatible `/v1` path.
```bash
export ZOPU_SMOKE_ACCESS_TOKEN='...'
export ZOPU_SMOKE_PROJECT_ID='...'
export ZOPU_SMOKE_DAEMON_ID='local-macbook'
export ZOPU_SMOKE_CPA_BASE_URL='https://ai.example.invalid/v1'
export ZOPU_SMOKE_CPA_API_KEY='...'
```
The harness reuses `AGENT_MODEL_*`, `CONVEX_URL`, `DAEMON_ID`, `SITE_URL`, and `VITE_FLUE_URL` when their `ZOPU_SMOKE_*` equivalents are absent. It never rewrites those values. Override the tiny feature request with `ZOPU_SMOKE_FEATURE_REQUEST` when the disposable project needs a more specific target.
## Commands
Run capability probes only:
```bash
bun run smoke:zopu
```
Drive one issue through the project loop and review it:
```bash
bun run smoke:zopu --run --report /tmp/zopu-smoke.json
```
The command prints one of these stable markers:
- `ZOPU_SMOKE_PREFLIGHT_PASSED` means all probes passed and no mutation was requested.
- `ZOPU_SMOKE_COMPLETED` means the issue was created, the worker reported completion, the durable issue reached `completed`, and GLM approved the result.
- `ZOPU_SMOKE_CONTRACT_BLOCKED` means a required capability or configuration is missing; the JSON report names the exact check and next action.
- `ZOPU_SMOKE_FAILED` means runtime execution started but did not satisfy the worker/reviewer contract.
Exit codes are `0` for a passing preflight or completed run, `2` for a contract block, and `1` for a runtime failure. Reports contain only endpoint labels, statuses, counts, ids, and sanitized failure text; model transcripts and credentials are intentionally omitted.
## Current lane boundary
The smoke intentionally stops before mutation when the project source, issue-scoped AgentOS artifacts, or online daemon contract is absent. Those checks are the integration boundary for the project-loop and daemon lanes; do not paper over them in this harness or make a live gateway change to satisfy a failed probe.

View File

@@ -40,6 +40,7 @@
"check": "ultracite check", "check": "ultracite check",
"lint": "vp lint", "lint": "vp lint",
"format": "vp fmt", "format": "vp fmt",
"smoke:zopu": "bun scripts/zopu-smoke.ts",
"staged": "vp staged", "staged": "vp staged",
"hooks:setup": "vp config", "hooks:setup": "vp config",
"dev:native": "vp run --filter native dev", "dev:native": "vp run --filter native dev",
@@ -62,16 +63,22 @@
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "latest", "oxfmt": "latest",
"oxlint": "latest", "oxlint": "latest",
"rolldown": "1.1.4", "rolldown": "1.1.4",
"typescript": "catalog:", "typescript": "catalog:",
"ultracite": "7.9.3", "ultracite": "7.9.3",
"vite-plus": "0.2.2", "vite-plus": "0.2.2",
"convex-test": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"overrides": { "overrides": {

View File

@@ -14,10 +14,12 @@
"dependencies": { "dependencies": {
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"hono": "4.12.30", "effect": "catalog:",
"hono": "4.12.31",
"valibot": "^1.4.2" "valibot": "^1.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,132 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent";
import { defineAction } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import * as v from "valibot";
import {
createGiteaHttpTransport,
runPostRunGiteaLifecycle,
} from "../git/gitea";
const pullRequestOutput = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.picklist(["open", "closed", "merged"]),
url: v.string(),
});
const output = v.object({
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
pullRequest: v.optional(pullRequestOutput),
status: v.picklist([
"no_changes",
"committed",
"pushed",
"pull_request_open",
"failed",
]),
});
export const createFinalizeGiteaLifecycle = (
issueAgentId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const issueId = issueAgentId as Id<"projectIssues">;
return defineAction({
description:
"After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
input: v.object({
commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
verified: v.boolean(),
}),
name: "finalize_gitea_lifecycle",
output,
async run({ harness, input, log }) {
const context = await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
});
if (!context.source) {
throw new Error("Project has no Git source configured");
}
if (!context.source.defaultBranch) {
throw new Error("Project Git source has no default branch");
}
if (!env.GITEA_TOKEN) {
throw new Error(
"GITEA_TOKEN is required for Gitea pull request creation"
);
}
const runner = {
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
return await harness.shell(command, options);
},
};
const transport = createGiteaHttpTransport({
baseUrl: env.GITEA_URL,
token: env.GITEA_TOKEN,
});
try {
const result = await runPostRunGiteaLifecycle({
baseBranch: context.source.defaultBranch,
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
commitMessage: input.commitMessage,
issueNumber: context.issue.number,
issueTitle: context.issue.title,
repositoryPath: context.source.repositoryPath,
runner,
title: `Issue #${context.issue.number}: ${context.issue.title}`,
transport,
verification: input.verified ? "passed" : "failed",
workspace: "/workspace/repository",
});
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: result.baseBranch,
branch: result.branch,
...(result.commitSha === undefined
? {}
: { commitSha: result.commitSha }),
issueId,
...(result.pullRequest === undefined
? {}
: { pullRequest: result.pullRequest }),
status: result.status,
token: env.FLUE_DB_TOKEN,
});
log.info("Gitea lifecycle completed", {
branch: result.branch,
status: result.status,
});
return result;
} catch (error) {
const branchResult = await runner.run("git branch --show-current", {
cwd: "/workspace/repository",
});
const branch = branchResult.stdout.trim() || "unknown";
const message = error instanceof Error ? error.message : String(error);
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: context.source.defaultBranch,
branch,
error: message,
issueId,
status: "failed",
token: env.FLUE_DB_TOKEN,
});
throw error;
}
},
});
};

View File

@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime"; import type { AgentRouteHandler } from "@flue/runtime";
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
import { agentOs } from "../sandboxes/agent-os"; import { agentOs } from "../sandboxes/agent-os";
import { createProjectTools } from "../tools/project"; import { createProjectTools } from "../tools/project";
@@ -14,15 +15,16 @@ export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return { return {
cwd: "/workspace", actions: [createFinalizeGiteaLifecycle(id, env)],
cwd: "/workspace/repository",
description, description,
instructions: `You are the issue-scoped project manager and coding agent. instructions: `You are the issue-scoped project manager and coding agent.
Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing. Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing.
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence. Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`, Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env), sandbox: agentOs(env),
tools: createProjectTools(id, env), tools: createProjectTools(id, env),

View File

@@ -3,6 +3,8 @@ import { registerProvider } from "@flue/runtime";
import { flue } from "@flue/runtime/routing"; import { flue } from "@flue/runtime/routing";
import { Hono } from "hono"; import { Hono } from "hono";
import { projectRequestRoute } from "./project-request";
const agentEnv = parseAgentEnv(process.env); const agentEnv = parseAgentEnv(process.env);
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, { registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
@@ -18,6 +20,7 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
}); });
const app = new Hono(); const app = new Hono();
app.post("/project-requests", projectRequestRoute);
app.route("/", flue()); app.route("/", flue());
export default app; export default app;

View File

@@ -98,7 +98,9 @@ const { CONVEX_URL } = parseAgentEnv(process.env);
* caller's JWT is set on this instance only and discarded when the request * caller's JWT is set on this instance only and discarded when the request
* ends. We never mutate auth on a shared/global Convex client. * ends. We never mutate auth on a shared/global Convex client.
*/ */
const createAuthenticatedClient = (accessToken: string): ConvexHttpClient => { export const createAuthenticatedClient = (
accessToken: string
): ConvexHttpClient => {
const client = new ConvexHttpClient(CONVEX_URL); const client = new ConvexHttpClient(CONVEX_URL);
client.setAuth(accessToken); client.setAuth(accessToken);
return client; return client;
@@ -120,7 +122,7 @@ interface HeaderSource {
* Extract and validate the Bearer access token from the request. Returns null * Extract and validate the Bearer access token from the request. Returns null
* when absent or malformed so the caller can produce a clean 401. * when absent or malformed so the caller can produce a clean 401.
*/ */
const extractBearerToken = (request: HeaderSource): string | null => { export const extractBearerToken = (request: HeaderSource): string | null => {
const header = request.headers.get("authorization"); const header = request.headers.get("authorization");
if (!header) { if (!header) {
return null; return null;

View File

@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
import type { GitCommandRunner, GiteaTransport } from "./gitea";
const repository = {
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
defaultBranch: "main",
htmlUrl: "https://git.openputer.com/puter/zopu-code",
name: "zopu-code",
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
};
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
const commands: string[] = [];
let statusCalls = 0;
return {
commands,
run(command) {
commands.push(command);
if (command === "git branch --show-current") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "work/issue-5\n",
});
}
if (command === "git status --porcelain=v1") {
statusCalls += 1;
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
});
}
if (command === "git diff --no-ext-diff --binary") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "diff --git ...",
});
}
if (command === "git rev-parse HEAD") {
return Promise.resolve({
exitCode: 0,
stderr: "",
stdout: "abc123\n",
});
}
if (command.includes("git status --porcelain=v1")) {
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
},
};
};
describe("Gitea lifecycle adapter", () => {
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
const runner = makeRunner();
const pullRequests: unknown[] = [];
const transport: GiteaTransport = {
createPullRequest(input) {
pullRequests.push(input);
return Promise.resolve({
base: { ref: input.base },
head: { ref: input.head },
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
});
},
getRepository() {
return Promise.resolve(repository);
},
};
const result = await runPostRunGiteaLifecycle({
baseBranch: "main",
body: "Generated by the verified project run.",
issueNumber: 5,
issueTitle: "Publish verified changes",
repositoryPath: "puter/zopu-code",
runner,
transport,
verification: "passed",
workspace: "/workspace",
});
expect(result).toMatchObject({
branch: "work/issue-5",
commitSha: "abc123",
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
},
status: "pull_request_open",
});
expect(runner.commands).toEqual([
"git branch --show-current",
"git status --porcelain=v1",
"git diff --no-ext-diff --binary",
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD",
"git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
]);
expect(pullRequests).toHaveLength(1);
});
test("mocks the HTTP Gitea boundary without calling the network", async () => {
const requests: Request[] = [];
const transport = createGiteaHttpTransport({
baseUrl: "https://git.openputer.com",
fetch: (input, init) => {
const request = new Request(String(input), init);
requests.push(request);
return Promise.resolve(
new Response(
request.method === "GET"
? JSON.stringify(repository)
: JSON.stringify({
base: { ref: "main" },
head: { ref: "work/issue-5" },
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
number: 5,
state: "open",
}),
{ headers: { "content-type": "application/json" } }
)
);
},
token: "scoped-test-token",
});
await transport.getRepository("puter/zopu-code");
await transport.createPullRequest({
base: "main",
body: "body",
head: "work/issue-5",
repositoryPath: "puter/zopu-code",
title: "title",
});
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
);
expect(requests[1]?.headers.get("authorization")).toBe(
"token scoped-test-token"
);
});
});

View File

@@ -0,0 +1,307 @@
import {
decideGitLifecycle,
GitLifecycleError,
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
} from "@code/primitives/git";
import type {
GitLifecycleDecisionResult,
GitLifecycleResult,
GitWorkspaceInspection,
} from "@code/primitives/git";
import { Effect } from "effect";
export interface GitCommandResult {
readonly exitCode: number;
readonly stderr: string;
readonly stdout: string;
}
export interface GitCommandRunner {
readonly run: (
command: string,
options?: {
readonly cwd?: string;
readonly env?: Record<string, string>;
}
) => Promise<GitCommandResult>;
}
export interface GiteaRepository {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly htmlUrl: string;
readonly name: string;
readonly sshUrl: string;
}
export interface GiteaPullRequest {
readonly base: { readonly ref: string };
readonly head: { readonly ref: string };
readonly htmlUrl: string;
readonly number: number;
readonly state: "open" | "closed" | "merged";
}
export interface GiteaTransport {
readonly createPullRequest: (input: {
readonly base: string;
readonly body: string;
readonly head: string;
readonly repositoryPath: string;
readonly title: string;
}) => Promise<GiteaPullRequest>;
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
}
export interface GiteaHttpTransportOptions {
readonly baseUrl: string;
readonly fetch?: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
readonly token: string;
}
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const providerFailure = (
message: string,
reason: "CommandFailed" | "RemoteRejected"
) => new GitLifecycleError({ message, reason });
const runChecked = async (
runner: GitCommandRunner,
command: string,
options?: Parameters<GitCommandRunner["run"]>[1]
): Promise<string> => {
let result: GitCommandResult;
try {
result = await runner.run(command, options);
} catch (error) {
throw new GitLifecycleError({
message: `Git command could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "CommandFailed",
});
}
if (result.exitCode !== 0) {
throw providerFailure(
result.stderr.trim() || `Git command failed: ${command}`,
command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
);
}
return result.stdout;
};
const toHttpPath = (repositoryPath: string): string =>
repositoryPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const requestJson = async <T>(
options: GiteaHttpTransportOptions,
path: string,
init?: RequestInit
): Promise<T> => {
let response: Response;
try {
response = await (options.fetch ?? globalThis.fetch)(
`${options.baseUrl.replace(/\/$/u, "")}${path}`,
{
...init,
headers: {
Accept: "application/json",
Authorization: `token ${options.token}`,
"Content-Type": "application/json",
...init?.headers,
},
}
);
} catch (error) {
throw new GitLifecycleError({
message: `Gitea request could not run: ${
error instanceof Error ? error.message : String(error)
}`,
reason: "RequestFailed",
});
}
if (!response.ok) {
const detail = await response.text();
throw new GitLifecycleError({
message: `Gitea request failed (${response.status}): ${detail}`,
reason:
response.status === 401 || response.status === 403
? "Authentication"
: "RequestFailed",
});
}
return (await response.json()) as T;
};
export const createGiteaHttpTransport = (
options: GiteaHttpTransportOptions
): GiteaTransport => ({
async createPullRequest(input) {
const response = await requestJson<{
base: { ref: string };
head: { ref: string };
html_url: string;
number: number;
state: "open" | "closed" | "merged";
}>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
body: JSON.stringify({
base: input.base,
body: input.body,
head: input.head,
title: input.title,
}),
method: "POST",
});
return {
base: response.base,
head: response.head,
htmlUrl: response.html_url,
number: response.number,
state: response.state,
};
},
async getRepository(repositoryPath) {
const response = await requestJson<{
clone_url: string;
default_branch: string;
html_url: string;
name: string;
ssh_url: string;
}>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
return {
cloneUrl: response.clone_url,
defaultBranch: response.default_branch,
htmlUrl: response.html_url,
name: response.name,
sshUrl: response.ssh_url,
};
},
});
export interface RunPostRunGiteaLifecycleInput {
readonly baseBranch: string;
readonly body: string;
readonly commitMessage?: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly runner: GitCommandRunner;
readonly title?: string;
readonly transport: GiteaTransport;
readonly verification: "passed" | "failed" | "not-run";
readonly workspace: string;
}
const inspect = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitWorkspaceInspection> => {
const [branch, status, diff] = await Promise.all([
runChecked(input.runner, "git branch --show-current", {
cwd: input.workspace,
}),
runChecked(input.runner, "git status --porcelain=v1", {
cwd: input.workspace,
}),
runChecked(input.runner, "git diff --no-ext-diff --binary", {
cwd: input.workspace,
}),
]);
return await Effect.runPromise(
inspectGitWorkspace({
branch: branch.trim(),
diff,
status,
})
);
};
export const runPostRunGiteaLifecycle = async (
input: RunPostRunGiteaLifecycleInput
): Promise<GitLifecycleResult> => {
const inspection = await inspect(input);
const initialDecision = (await Effect.runPromise(
decideGitLifecycle({
baseBranch: input.baseBranch,
inspection,
pushed: false,
verification: input.verification,
})
)) as GitLifecycleDecisionResult;
if (initialDecision.decision === "finish") {
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha: undefined,
pullRequest: undefined,
status: "no_changes",
};
}
const repository = await input.transport.getRepository(input.repositoryPath);
await runChecked(
input.runner,
`git add --all && git commit -m ${shellQuote(
input.commitMessage ??
makeCommitMessage(input.issueNumber, input.issueTitle)
)}`,
{ cwd: input.workspace }
);
const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
cwd: input.workspace,
});
const commitSha = commitOutput.trim();
const cleanStatus = await runChecked(
input.runner,
"git status --porcelain=v1",
{ cwd: input.workspace }
);
if (cleanStatus.trim().length > 0) {
throw providerFailure(
"Git workspace remained dirty after commit",
"CommandFailed"
);
}
await runChecked(
input.runner,
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
{ cwd: input.workspace }
);
const pullRequest = await input.transport.createPullRequest({
base: input.baseBranch,
body: input.body,
head: inspection.branch,
repositoryPath: input.repositoryPath,
title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
});
const metadata = await Effect.runPromise(
validatePullRequestMetadata({
baseBranch: pullRequest.base.ref,
branch: pullRequest.head.ref,
number: pullRequest.number,
status: pullRequest.state,
url: pullRequest.htmlUrl,
})
);
return {
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
branch: inspection.branch,
commitSha,
pullRequest: metadata,
status: "pull_request_open",
};
};

View File

@@ -0,0 +1,162 @@
import {
decodeProjectIssueRequest,
ProjectIssueDispatchInput,
ProjectIssueRequestError,
ProjectIssueRequestResult,
ProjectIssueValidationError,
} from "@code/primitives/project-issue";
import type { ProjectIssueRequest } from "@code/primitives/project-issue";
import { dispatch } from "@flue/runtime";
import type { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import { Effect, Schema } from "effect";
import type { Context } from "hono";
import { createAuthenticatedClient, extractBearerToken } from "./auth";
interface ProjectIssueCreateArgs extends Record<string, unknown> {
readonly body: string;
readonly projectId: string;
readonly title: string;
}
const createIssue = makeFunctionReference<
"mutation",
ProjectIssueCreateArgs,
string
>("projectIssues:create");
const createIssueFromSignal = makeFunctionReference<
"mutation",
{ readonly signalId: string },
{ readonly issueId: string; readonly projectId: string }
>("projectIssues:createFromSignal");
const beginIssue = makeFunctionReference<
"mutation",
{ readonly issueId: string },
"queued" | "working"
>("projectIssues:begin");
const markDispatchFailed = makeFunctionReference<
"mutation",
{ readonly error: string; readonly issueId: string },
null
>("projectIssues:markDispatchFailed");
const invalidRequest = (message: string) =>
Response.json({ error: message }, { status: 400 });
const knownAuthorizationFailure = (message: string): boolean =>
/authentication required|membership required|project not found|signal not found|not project-scoped/iu.test(
message
);
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const decodeRequest = async (input: unknown): Promise<ProjectIssueRequest> => {
try {
return await Effect.runPromise(decodeProjectIssueRequest(input));
} catch (error) {
if (
error instanceof ProjectIssueRequestError ||
error instanceof ProjectIssueValidationError
) {
throw invalidRequest(
error instanceof Error ? error.message : "Invalid project request"
);
}
throw error;
}
};
const createIssueForRequest = async (
client: ConvexHttpClient,
request: Awaited<ReturnType<typeof decodeRequest>>
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
if (request.kind === "signal") {
return client.mutation(createIssueFromSignal, {
signalId: request.signalId,
});
}
const issueId = await client.mutation(createIssue, {
body: request.body,
projectId: request.projectId,
title: request.title,
});
return { issueId, projectId: request.projectId };
};
export const projectRequestRoute = async (c: Context): Promise<Response> => {
const accessToken = extractBearerToken(c.req.raw);
if (!accessToken) {
return c.json({ error: "Unauthorized" }, 401);
}
let input: unknown;
try {
input = await c.req.json();
} catch {
return invalidRequest("Request body must be valid JSON");
}
let request: Awaited<ReturnType<typeof decodeRequest>>;
try {
request = await decodeRequest(input);
} catch (error) {
if (error instanceof Response) {
return error;
}
return c.json({ error: "Invalid project request" }, 400);
}
const client = createAuthenticatedClient(accessToken);
let issue:
| { readonly issueId: string; readonly projectId: string }
| undefined;
try {
issue = await createIssueForRequest(client, request);
const status = await client.mutation(beginIssue, {
issueId: issue.issueId,
});
const dispatchInput = Schema.decodeUnknownSync(ProjectIssueDispatchInput)({
issueId: issue.issueId,
kind: "project.issue.started",
projectId: issue.projectId,
});
const receipt = await dispatch({
agent: "project-manager",
id: issue.issueId,
input: dispatchInput,
});
const result = Schema.decodeUnknownSync(ProjectIssueRequestResult)({
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
issueId: issue.issueId,
projectId: issue.projectId,
status,
});
return c.json(result, 202);
} catch (error) {
const message = errorMessage(error);
if (issue) {
try {
await client.mutation(markDispatchFailed, {
error: message,
issueId: issue.issueId,
});
} catch {
// Preserve the original request failure; the issue remains inspectable.
}
}
return c.json(
{
error: knownAuthorizationFailure(message)
? "Project request is not authorized"
: "Project request could not be dispatched",
},
knownAuthorizationFailure(message) ? 403 : 502
);
}
};

View File

@@ -1,9 +1,14 @@
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import {
decodeIssueWorkspaceResult,
makeIssueWorkspacePlan,
} from "@code/primitives/project-workspace";
import { createSandboxSessionEnv } from "@flue/runtime"; import { createSandboxSessionEnv } from "@flue/runtime";
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime"; import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser"; import { ConvexHttpClient } from "convex/browser";
import { Effect } from "effect";
import * as v from "valibot"; import * as v from "valibot";
const execResultSchema = v.object({ const execResultSchema = v.object({
@@ -218,15 +223,69 @@ export const agentOs = (
issueId, issueId,
token: env.FLUE_DB_TOKEN, token: env.FLUE_DB_TOKEN,
}); });
await sandbox.mkdir("/workspace", { recursive: true });
await Promise.all( const plan = await Effect.runPromise(
context.artifacts.map((artifact) => makeIssueWorkspacePlan({
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content) artifacts: context.artifacts.map((artifact) => ({
) content: artifact.content,
path: artifact.path,
})),
branchName: context.run?.branchName,
checkoutPath: context.run?.checkoutPath,
contextFiles: context.contextDocuments.map((document) => ({
content: document.content,
kind: document.kind,
path: document.path,
})),
defaultBranch:
context.run?.baseBranch ?? context.source?.defaultBranch,
issueBody: context.issue.body,
issueId: String(context.issue._id),
issueNumber: context.issue.number,
issueTitle: context.issue.title,
sourceUrl: context.run?.sourceUrl ?? context.source?.url,
})
); );
await sandbox.writeFile(
"/workspace/issue.md", const [controlDirectory, checkoutOperation, ...stagingOperations] =
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n` plan.operations;
if (!controlDirectory || controlDirectory._tag !== "Mkdir") {
throw new Error(
"Issue workspace plan must start with a control directory"
);
}
await sandbox.mkdir(controlDirectory.path, { recursive: true });
if (!checkoutOperation || checkoutOperation._tag !== "Exec") {
throw new Error("Issue workspace plan must include a checkout command");
}
const checkoutCommandResult = await sandbox.exec(
checkoutOperation.command,
{
cwd: checkoutOperation.cwd,
timeoutMs: checkoutOperation.timeoutMs,
}
);
await Promise.all(
stagingOperations
.filter((operation) => operation._tag === "Mkdir")
.map((operation) =>
sandbox.mkdir(operation.path, { recursive: true })
)
);
await Promise.all(
stagingOperations
.filter((operation) => operation._tag === "WriteFile")
.map((operation) =>
sandbox.writeFile(operation.path, operation.content)
)
);
await Effect.runPromise(
decodeIssueWorkspaceResult({
command: checkoutCommandResult,
plan,
})
); );
return createSandboxSessionEnv(sandbox, "/workspace"); return createSandboxSessionEnv(sandbox, "/workspace");
}, },

View File

@@ -12,6 +12,7 @@ import type * as agentWorkspace from "../agentWorkspace.js";
import type * as artifactModel from "../artifactModel.js"; import type * as artifactModel from "../artifactModel.js";
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as authz from "../authz.js"; import type * as authz from "../authz.js";
import type * as conversationMessages from "../conversationMessages.js";
import type * as daemonCommands from "../daemonCommands.js"; import type * as daemonCommands from "../daemonCommands.js";
import type * as daemonRuntime from "../daemonRuntime.js"; import type * as daemonRuntime from "../daemonRuntime.js";
import type * as daemons from "../daemons.js"; import type * as daemons from "../daemons.js";
@@ -22,7 +23,10 @@ import type * as organizations from "../organizations.js";
import type * as privateData from "../privateData.js"; import type * as privateData from "../privateData.js";
import type * as projectArtifacts from "../projectArtifacts.js"; import type * as projectArtifacts from "../projectArtifacts.js";
import type * as projectIssues from "../projectIssues.js"; import type * as projectIssues from "../projectIssues.js";
import type * as projectStore from "../projectStore.js";
import type * as projects from "../projects.js"; import type * as projects from "../projects.js";
import type * as publicGit from "../publicGit.js";
import type * as signals from "../signals.js";
import type * as todos from "../todos.js"; import type * as todos from "../todos.js";
import type { import type {
@@ -36,6 +40,7 @@ declare const fullApi: ApiFromModules<{
artifactModel: typeof artifactModel; artifactModel: typeof artifactModel;
auth: typeof auth; auth: typeof auth;
authz: typeof authz; authz: typeof authz;
conversationMessages: typeof conversationMessages;
daemonCommands: typeof daemonCommands; daemonCommands: typeof daemonCommands;
daemonRuntime: typeof daemonRuntime; daemonRuntime: typeof daemonRuntime;
daemons: typeof daemons; daemons: typeof daemons;
@@ -46,7 +51,10 @@ declare const fullApi: ApiFromModules<{
privateData: typeof privateData; privateData: typeof privateData;
projectArtifacts: typeof projectArtifacts; projectArtifacts: typeof projectArtifacts;
projectIssues: typeof projectIssues; projectIssues: typeof projectIssues;
projectStore: typeof projectStore;
projects: typeof projects; projects: typeof projects;
publicGit: typeof publicGit;
signals: typeof signals;
todos: typeof todos; todos: typeof todos;
}>; }>;

View File

@@ -1,5 +1,12 @@
import { env } from "@code/env/convex"; import { env } from "@code/env/convex";
import {
makeIssueWorkspacePlan,
transitionWorkspaceRun,
WorkspaceStateError,
WorkspaceValidationError,
} from "@code/primitives/project-workspace";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { artifactPath } from "./artifactModel"; import { artifactPath } from "./artifactModel";
@@ -17,6 +24,37 @@ const agentStatus = v.union(
v.literal("failed") v.literal("failed")
); );
const workspacePlanFor = (input: {
readonly issueBody: string;
readonly issueId: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly sourceUrl: string | undefined;
readonly defaultBranch: string | undefined;
}) => {
try {
return Effect.runSync(
makeIssueWorkspacePlan({
artifacts: [],
branchName: undefined,
checkoutPath: undefined,
contextFiles: [],
defaultBranch: input.defaultBranch,
issueBody: input.issueBody,
issueId: input.issueId,
issueNumber: input.issueNumber,
issueTitle: input.issueTitle,
sourceUrl: input.sourceUrl,
})
);
} catch (error) {
if (error instanceof WorkspaceValidationError) {
throw new ConvexError(error.message);
}
throw error;
}
};
export const get = query({ export const get = query({
args: { issueId: v.id("projectIssues"), token: v.string() }, args: { issueId: v.id("projectIssues"), token: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -33,7 +71,26 @@ export const get = query({
.query("projectArtifacts") .query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", project._id)) .withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(25); .take(25);
return { artifacts, issue, project }; const [source] = await ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(1);
const contextDocuments = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(25);
const run = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
return {
artifacts,
contextDocuments,
issue,
project,
run: run ?? null,
source: source ?? null,
};
}, },
}); });
@@ -49,12 +106,38 @@ export const ensureRun = mutation({
if (!issue) { if (!issue) {
throw new ConvexError("Issue not found"); throw new ConvexError("Issue not found");
} }
const [source] = await ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", issue.projectId))
.take(1);
const plan = workspacePlanFor({
defaultBranch: source?.defaultBranch,
issueBody: issue.body,
issueId: String(issue._id),
issueNumber: issue.number,
issueTitle: issue.title,
sourceUrl: source?.url,
});
const existing = await ctx.db const existing = await ctx.db
.query("projectWorkRuns") .query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id)) .withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique(); .unique();
if (existing) { if (existing) {
return existing; if (
existing.baseBranch !== plan.baseBranch ||
existing.branchName !== plan.branchName ||
existing.checkoutPath !== plan.checkoutPath ||
existing.sourceUrl !== plan.sourceUrl
) {
await ctx.db.patch("projectWorkRuns", existing._id, {
baseBranch: plan.baseBranch,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
sourceUrl: plan.sourceUrl,
updatedAt: Date.now(),
});
}
return await ctx.db.get("projectWorkRuns", existing._id);
} }
const timestamp = Date.now(); const timestamp = Date.now();
const actorKey = [ const actorKey = [
@@ -66,16 +149,26 @@ export const ensureRun = mutation({
const runId = await ctx.db.insert("projectWorkRuns", { const runId = await ctx.db.insert("projectWorkRuns", {
actorKey, actorKey,
agentId: String(issue._id), agentId: String(issue._id),
baseBranch: plan.baseBranch,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
createdAt: timestamp, createdAt: timestamp,
daemonId: args.daemonId, daemonId: args.daemonId,
issueId: issue._id, issueId: issue._id,
projectId: issue.projectId, projectId: issue.projectId,
sourceUrl: plan.sourceUrl,
status: "queued", status: "queued",
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectEvents", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { actorKey, daemonId: args.daemonId }, data: {
actorKey,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
daemonId: args.daemonId,
sourceUrl: plan.sourceUrl,
},
issueId: issue._id, issueId: issue._id,
kind: "agent.workspace.created", kind: "agent.workspace.created",
projectId: issue.projectId, projectId: issue.projectId,
@@ -147,6 +240,19 @@ export const setStatus = mutation({
if (!run) { if (!run) {
throw new ConvexError("Agent work run not found"); throw new ConvexError("Agent work run not found");
} }
try {
Effect.runSync(
transitionWorkspaceRun({
from: run.status,
to: args.status,
})
);
} catch (error) {
if (error instanceof WorkspaceStateError) {
throw new ConvexError(error.message);
}
throw error;
}
const timestamp = Date.now(); const timestamp = Date.now();
const terminal = args.status === "completed" || args.status === "failed"; const terminal = args.status === "completed" || args.status === "failed";
await ctx.db.patch("projectIssues", issue._id, { await ctx.db.patch("projectIssues", issue._id, {
@@ -172,3 +278,111 @@ export const setStatus = mutation({
}); });
}, },
}); });
const lifecycleStatus = v.union(
v.literal("no_changes"),
v.literal("committed"),
v.literal("pushed"),
v.literal("pull_request_open"),
v.literal("failed")
);
const pullRequestMetadata = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
url: v.string(),
});
export const recordGiteaLifecycle = mutation({
args: {
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
error: v.optional(v.string()),
issueId: v.id("projectIssues"),
pullRequest: v.optional(pullRequestMetadata),
status: lifecycleStatus,
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
const run = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
if (!run) {
throw new ConvexError("Agent work run not found");
}
const events = await ctx.db
.query("projectEvents")
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
.order("desc")
.take(100);
const existing = events.find((event) => {
if (!event.kind.startsWith("gitea.")) {
return false;
}
const data = event.data as {
branch?: unknown;
commitSha?: unknown;
status?: unknown;
};
return (
data.branch === args.branch &&
data.commitSha === args.commitSha &&
data.status === args.status
);
});
if (existing) {
return existing.data;
}
const timestamp = Date.now();
const data = {
baseBranch: args.baseBranch,
branch: args.branch,
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
...(args.pullRequest === undefined
? {}
: { pullRequest: args.pullRequest }),
status: args.status,
};
const artifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
)
.unique();
if (artifact) {
const pullRequestLine = args.pullRequest
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
: "";
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
await ctx.db.patch("projectArtifacts", artifact._id, {
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
revision: artifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data,
issueId: issue._id,
kind:
args.pullRequest === undefined
? "gitea.lifecycle.updated"
: "gitea.pull_request.created",
projectId: issue.projectId,
runId: run._id,
});
return data;
},
});

View File

@@ -168,11 +168,17 @@ describe("projectEvents", () => {
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId }); .mutation(api.projectIssues.begin, { issueId });
await t.mutation(api.agentWorkspace.ensureRun, { const run = await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1", daemonId: "daemon-1",
issueId, issueId,
token: FLUE_DB_TOKEN, token: FLUE_DB_TOKEN,
}); });
expect(run).toMatchObject({
baseBranch: "main",
checkoutPath: "/workspace/repository",
sourceUrl: SOURCE.url,
});
expect(run?.branchName).toMatch(/^work\/issue-1-/u);
await t.mutation(api.agentWorkspace.setStatus, { await t.mutation(api.agentWorkspace.setStatus, {
issueId, issueId,
status: "completed", status: "completed",
@@ -217,4 +223,63 @@ describe("projectEvents", () => {
const events = await eventsForProject(t, projectId); const events = await eventsForProject(t, projectId);
expect(events.some((e) => e.kind === "agent.completed")).toBe(false); expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
}); });
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
const t = convexTest({ schema, modules });
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1",
issueId,
token: FLUE_DB_TOKEN,
});
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: "main",
branch: "work/issue-1",
commitSha: "abc123",
issueId,
pullRequest: {
baseBranch: "main",
branch: "work/issue-1",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
},
status: "pull_request_open",
token: FLUE_DB_TOKEN,
});
const events = await eventsForProject(t, projectId);
const event = events.find(
(item) => item.kind === "gitea.pull_request.created"
);
expect(event?.data).toMatchObject({
branch: "work/issue-1",
commitSha: "abc123",
pullRequest: {
number: 5,
status: "open",
},
status: "pull_request_open",
});
const artifacts = await t.query((ctx) =>
ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", "artifacts.md")
)
.unique()
);
expect(artifacts?.content).toContain(
"https://git.openputer.com/puter/zopu-code/pulls/5"
);
});
}); });

View File

@@ -0,0 +1,177 @@
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|project-request-a";
const ID_B = "https://convex.test|project-request-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const createProject = async (
t: TestConvex<typeof schema>
): Promise<Id<"projects">> => {
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const outcome = await t
.withIdentity(identityA)
.mutation(internal.projects.persistPublicGitImport, {
userId: ID_A,
source: {
host: "github.com",
normalizedUrl: "https://github.com/test/project-request",
projectName: "project-request",
repositoryPath: "test/project-request",
url: "https://github.com/test/project-request",
},
remote: {
defaultBranch: "main",
documents: [
{
content: "# Project Request\n",
kind: "readme" as const,
path: "README.md",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
describe("project issue request smoke", () => {
test("project Signal becomes a queued project issue with durable evidence", async () => {
const t = convexTest({ schema, modules });
const projectId = await createProject(t);
const organization = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "project-request-1",
organizationId: organization._id,
rawText: "Staging deploys fail during DNS resolution.",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "project-request-1",
organizationId: organization._id,
submissionId: "submission-project-request-1",
});
const signal = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: organization._id,
messageIds: [begun.messageId],
organizationId: organization._id,
problemStatement: {
constraints: ["Do not change production"],
desiredOutcome: "Staging deploys successfully",
summary: "The staging deploy fails during DNS resolution.",
title: "Fix the staging deploy DNS failure",
},
processedBy: {
agentInstanceId: String(organization._id),
agentName: "zopu",
},
projectId,
});
const created = await t
.withIdentity(identityA)
.mutation(api.projectIssues.createFromSignal, {
signalId: signal.signalId,
});
const issue = await t.run(async (ctx) => ctx.db.get(created.issueId));
expect(issue).toMatchObject({
body: expect.stringContaining("Source Signal"),
projectId,
status: "open",
title: "Fix the staging deploy DNS failure",
});
const status = await t
.withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId: created.issueId });
expect(status).toBe("queued");
const events = await t.run(async (ctx) =>
ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", projectId)
)
.collect()
);
expect(events.map((event) => event.kind)).toEqual([
"project.connected",
"issue.created",
"issue.queued",
]);
});
test("a different organization cannot consume the project Signal", async () => {
const t = convexTest({ schema, modules });
const projectId = await createProject(t);
await t
.withIdentity(identityB)
.mutation(api.organizations.ensurePersonalOrganization, {});
const organization = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "project-request-cross-org",
organizationId: organization._id,
rawText: "Private project request",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "project-request-cross-org",
organizationId: organization._id,
submissionId: "submission-project-request-cross-org",
});
const signal = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: organization._id,
messageIds: [begun.messageId],
organizationId: organization._id,
problemStatement: {
constraints: [],
desiredOutcome: "Keep the request private",
summary: "This request belongs to the first organization.",
title: "Private project request",
},
processedBy: {
agentInstanceId: String(organization._id),
agentName: "zopu",
},
projectId,
});
await expect(
t.withIdentity(identityB).mutation(api.projectIssues.createFromSignal, {
signalId: signal.signalId,
})
).rejects.toThrow(/membership required/u);
});
});

View File

@@ -1,8 +1,82 @@
import {
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
type ProjectIssueDraft,
} from "@code/primitives/project-issue";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel";
import { mutation, type MutationCtx, query } from "./_generated/server";
import { requireProjectMember } from "./authz"; import { requireProjectMember } from "./authz";
const toDomainError = (error: unknown) => {
return new ConvexError(
error instanceof Error ? error.message : "Invalid project issue"
);
};
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
try {
return await Effect.runPromise(validateProjectIssueDraft(input));
} catch (error) {
throw toDomainError(error);
}
};
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
try {
return await Effect.runPromise(projectIssueDraftFromSignal(input));
} catch (error) {
throw toDomainError(error);
}
};
const insertIssue = async (
ctx: MutationCtx,
projectId: Id<"projects">,
draft: ProjectIssueDraft
): Promise<Id<"projectIssues">> => {
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body: draft.body,
createdAt: timestamp,
number,
projectId,
status: "open",
title: draft.title,
updatedAt: timestamp,
});
const workArtifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, title: draft.title },
issueId,
kind: "issue.created",
projectId,
});
return issueId;
};
export const create = mutation({ export const create = mutation({
args: { args: {
body: v.string(), body: v.string(),
@@ -11,55 +85,36 @@ export const create = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId); await requireProjectMember(ctx, args.projectId);
const title = args.title.trim(); const draft = await decodeDraft({ body: args.body, title: args.title });
const body = args.body.trim(); return insertIssue(ctx, args.projectId, draft);
if (title.length < 3 || title.length > 160) { },
throw new ConvexError("Issue title must be between 3 and 160 characters"); });
export const createFromSignal = mutation({
args: { signalId: v.id("signals") },
handler: async (ctx, args) => {
const signal = await ctx.db.get("signals", args.signalId);
if (!signal) {
throw new ConvexError("Signal not found");
} }
if (body.length < 10 || body.length > 10_000) { if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
const { organizationId } = await requireProjectMember(
ctx,
signal.projectId
);
if (signal.organizationId !== organizationId) {
throw new ConvexError( throw new ConvexError(
"Issue description must be between 10 and 10000 characters" "Signal does not belong to the project organization"
); );
} }
const latest = await ctx.db const draft = await draftFromSignal({
.query("projectIssues") problemStatement: signal.problemStatement,
.withIndex("by_project_and_number", (q) => signalId: String(signal._id),
q.eq("projectId", args.projectId)
)
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body,
createdAt: timestamp,
number,
projectId: args.projectId,
status: "open",
title,
updatedAt: timestamp,
}); });
const workArtifact = await ctx.db const issueId = await insertIssue(ctx, signal.projectId, draft);
.query("projectArtifacts") return { issueId, projectId: signal.projectId };
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", args.projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, title },
issueId,
kind: "issue.created",
projectId: args.projectId,
});
return issueId;
}, },
}); });
@@ -71,12 +126,18 @@ export const begin = mutation({
throw new ConvexError("Issue not found"); throw new ConvexError("Issue not found");
} }
await requireProjectMember(ctx, issue.projectId); await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") { let status: "queued" | "working";
return issue.status; try {
status = await Effect.runPromise(queueProjectIssue(issue.status));
} catch (error) {
throw toDomainError(error);
}
if (status === issue.status) {
return status;
} }
const timestamp = Date.now(); const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, { await ctx.db.patch("projectIssues", issue._id, {
status: "queued", status,
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectEvents", { await ctx.db.insert("projectEvents", {
@@ -130,3 +191,21 @@ export const list = query({
.take(100); .take(100);
}, },
}); });
export const events = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
},
});

View File

@@ -12,7 +12,11 @@ import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireCurrentOrganization } from "./authz"; import { requireCurrentOrganization } from "./authz";
import { persistPublicGitImportTransaction } from "./projectStore"; import {
makeProjectMutationStore,
makeProjectQueryStore,
persistPublicGitImportTransaction,
} from "./projectStore";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction // Internal: persist a public Git import in one transaction
@@ -146,7 +150,6 @@ export const putContextDocument = internalMutation({
), ),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx); const store = makeProjectMutationStore(ctx);
return store.putContext(args.userId, args.projectId, args.write as never); return store.putContext(args.userId, args.projectId, args.write as never);
}, },
@@ -160,7 +163,6 @@ export const list = query({
args: {}, args: {},
handler: async (ctx): Promise<ProjectView[]> => { handler: async (ctx): Promise<ProjectView[]> => {
const { userId } = await requireCurrentOrganization(ctx); const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx); const store = makeProjectQueryStore(ctx);
return store.listProjects(userId); return store.listProjects(userId);
}, },
@@ -174,7 +176,6 @@ export const get = query({
args: { projectId: v.id("projects") }, args: { projectId: v.id("projects") },
handler: async (ctx, args): Promise<ProjectView | null> => { handler: async (ctx, args): Promise<ProjectView | null> => {
const { userId } = await requireCurrentOrganization(ctx); const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx); const store = makeProjectQueryStore(ctx);
return store.getProject(userId, args.projectId); return store.getProject(userId, args.projectId);
}, },
@@ -216,7 +217,6 @@ export const putText = internalMutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { userId } = await requireCurrentOrganization(ctx); const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx); const store = makeProjectMutationStore(ctx);
return store.putContext(userId, args.projectId, { return store.putContext(userId, args.projectId, {
_tag: "UserText" as const, _tag: "UserText" as const,

View File

@@ -0,0 +1,125 @@
import {
CONTEXT_KINDS,
contextPathForKind,
MAX_CONTEXT_DOCUMENT_CHARACTERS,
type PreparedPublicGitSource,
type PublicGitImportResult,
type RepositoryContextDocument,
} from "@code/primitives/project";
const GITHUB_HOST = "github.com";
const REQUEST_HEADERS = {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-project-importer",
} as const;
const candidatePaths = {
agents: ["AGENTS.md"],
business: ["business.md", "docs/business.md"],
design: ["design.md", "docs/design.md"],
product: ["product.md", "docs/product.md"],
readme: ["README.md", "README", "readme.md"],
tech: ["tech.md", "docs/tech.md"],
} as const;
const encodePath = (path: string) =>
path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const fetchTextCandidate = async (
repositoryPath: string,
branch: string,
path: string
): Promise<string | null> => {
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`;
const response = await fetch(url, { headers: REQUEST_HEADERS });
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`GitHub returned ${response.status} for ${path}`);
}
const content = await response.text();
if (content.trim().length === 0) {
return null;
}
if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
throw new Error(`${path} exceeds the 200000 character import limit`);
}
return content;
};
const fetchContextDocument = async (
source: PreparedPublicGitSource,
branch: string,
kind: (typeof CONTEXT_KINDS)[number]
): Promise<RepositoryContextDocument | null> => {
for (const candidate of candidatePaths[kind]) {
const content = await fetchTextCandidate(
source.repositoryPath,
branch,
candidate
);
if (content) {
return {
content,
kind,
path: contextPathForKind(kind),
};
}
}
return null;
};
const readDefaultBranch = async (
source: PreparedPublicGitSource
): Promise<string> => {
if (source.host !== GITHUB_HOST) {
throw new Error("Repository import currently supports public GitHub URLs");
}
const response = await fetch(
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
{ headers: REQUEST_HEADERS }
);
if (!response.ok) {
throw new Error(
response.status === 404
? "Public GitHub repository not found"
: `GitHub returned ${response.status} while reading the repository`
);
}
const metadata = (await response.json()) as { default_branch?: unknown };
if (
typeof metadata.default_branch !== "string" ||
metadata.default_branch.length === 0
) {
throw new Error("GitHub did not return a default branch");
}
return metadata.default_branch;
};
export const inspectPublicGit = async (
source: PreparedPublicGitSource
): Promise<PublicGitImportResult> => {
const defaultBranch = await readDefaultBranch(source);
const candidates = await Promise.all(
CONTEXT_KINDS.map((kind) =>
fetchContextDocument(source, defaultBranch, kind)
)
);
const documents = candidates.filter(
(document): document is RepositoryContextDocument => document !== null
);
return {
defaultBranch,
documents,
warnings: CONTEXT_KINDS.filter(
(kind) => !documents.some((document) => document.kind === kind)
).map((kind) => ({
message: "Canonical context file was not found in the repository",
path: contextPathForKind(kind),
})),
};
};

View File

@@ -143,11 +143,15 @@ export default defineSchema({
.index("by_project_and_number", ["projectId", "number"]) .index("by_project_and_number", ["projectId", "number"])
.index("by_project_and_status", ["projectId", "status"]), .index("by_project_and_status", ["projectId", "status"]),
projectWorkRuns: defineTable({ projectWorkRuns: defineTable({
baseBranch: v.optional(v.string()),
branchName: v.optional(v.string()),
checkoutPath: v.optional(v.string()),
projectId: v.id("projects"), projectId: v.id("projects"),
issueId: v.id("projectIssues"), issueId: v.id("projectIssues"),
agentId: v.string(), agentId: v.string(),
daemonId: v.string(), daemonId: v.string(),
actorKey: v.array(v.string()), actorKey: v.array(v.string()),
sourceUrl: v.optional(v.string()),
status: v.union( status: v.union(
v.literal("ready"), v.literal("ready"),
v.literal("queued"), v.literal("queued"),

View File

@@ -8,8 +8,12 @@
"./convex": "./src/convex.ts", "./convex": "./src/convex.ts",
"./native": "./src/native.ts", "./native": "./src/native.ts",
"./server": "./src/server.ts", "./server": "./src/server.ts",
"./smoke": "./src/smoke.ts",
"./web": "./src/web.ts" "./web": "./src/web.ts"
}, },
"scripts": {
"check-types": "tsc --noEmit"
},
"dependencies": { "dependencies": {
"@t3-oss/env-core": "^0.13.11", "@t3-oss/env-core": "^0.13.11",
"zod": "catalog:" "zod": "catalog:"

View File

@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
CONVEX_URL: z.url(), CONVEX_URL: z.url(),
DAEMON_ID: z.string().min(1), DAEMON_ID: z.string().min(1),
FLUE_DB_TOKEN: z.string().min(1), FLUE_DB_TOKEN: z.string().min(1),
GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"),
}); });
export type AgentEnv = z.infer<typeof agentEnvSchema>; export type AgentEnv = z.infer<typeof agentEnvSchema>;

46
packages/env/src/smoke.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import { z } from "zod";
const smokeEnvSchema = z.object({
accessToken: z.string().min(1),
agentModelBaseUrl: z.url().optional(),
agentModelName: z.string().min(1).optional(),
agentModelProvider: z.string().min(1).optional(),
convexUrl: z.url(),
cpaApiKey: z.string().min(1),
cpaBaseUrl: z.url(),
daemonId: z.string().min(1),
featureRequest: z.string().min(10),
flueUrl: z.url(),
projectId: z.string().min(1).optional(),
webUrl: z.url(),
});
export type SmokeEnv = z.infer<typeof smokeEnvSchema>;
export const parseSmokeEnv = (
runtimeEnv: Record<string, string | undefined>
): SmokeEnv =>
smokeEnvSchema.parse({
accessToken: runtimeEnv.ZOPU_SMOKE_ACCESS_TOKEN,
agentModelBaseUrl: runtimeEnv.AGENT_MODEL_BASE_URL,
agentModelName: runtimeEnv.AGENT_MODEL_NAME,
agentModelProvider: runtimeEnv.AGENT_MODEL_PROVIDER,
convexUrl: runtimeEnv.ZOPU_SMOKE_CONVEX_URL ?? runtimeEnv.CONVEX_URL,
cpaApiKey:
runtimeEnv.ZOPU_SMOKE_CPA_API_KEY ?? runtimeEnv.AGENT_MODEL_API_KEY,
cpaBaseUrl:
runtimeEnv.ZOPU_SMOKE_CPA_BASE_URL ?? runtimeEnv.AGENT_MODEL_BASE_URL,
daemonId: runtimeEnv.ZOPU_SMOKE_DAEMON_ID ?? runtimeEnv.DAEMON_ID,
featureRequest:
runtimeEnv.ZOPU_SMOKE_FEATURE_REQUEST ??
"Add a tiny accessible status control to the existing web project's primary page, keep existing behavior unchanged, and add a focused test for it.",
flueUrl:
runtimeEnv.ZOPU_SMOKE_FLUE_URL ??
runtimeEnv.VITE_FLUE_URL ??
"http://localhost:3583",
projectId: runtimeEnv.ZOPU_SMOKE_PROJECT_ID,
webUrl:
runtimeEnv.ZOPU_SMOKE_WEB_URL ??
runtimeEnv.SITE_URL ??
"http://localhost:5173",
});

View File

@@ -6,8 +6,13 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts", "./agent-os": "./src/agent-os.ts",
"./git": "./src/git.ts",
"./project": "./src/project.ts", "./project": "./src/project.ts",
"./signal": "./src/signal.ts" "./project-issue": "./src/project-issue.ts",
"./project-workspace": "./src/project-workspace.ts",
"./project-work": "./src/project-work.ts",
"./signal": "./src/signal.ts",
"./smoke": "./src/smoke.ts"
}, },
"scripts": { "scripts": {
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
@@ -15,6 +20,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "catalog:",
"effect": "catalog:" "effect": "catalog:"
}, },

View File

@@ -1,9 +1,16 @@
import { /* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
agentOS as createAgentOsActor, import git from "@agentos-software/git";
type AgentOSConfigInput, import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
} from "@rivet-dev/agentos"; import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
const withGitSoftware = (
config: AgentOSConfigInput<undefined>
): AgentOSConfigInput<undefined> => ({
...config,
software: [git, ...(config.software ?? [])],
});
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()( export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
"AgentOsError", "AgentOsError",
{ {
@@ -29,12 +36,12 @@ export class AgentOs extends Context.Service<
static readonly layer = Layer.succeed( static readonly layer = Layer.succeed(
AgentOs, AgentOs,
AgentOs.of({ AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* ( createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = {} config: AgentOSConfigInput<undefined> = {}
) { ) {
return yield* Effect.try({ return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }), catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
}); });
}), }),
}) })
@@ -45,19 +52,19 @@ export const makeAgentOsLayer = (options: AgentOsOptions = {}) =>
Layer.succeed( Layer.succeed(
AgentOs, AgentOs,
AgentOs.of({ AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* ( createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = options.config ?? {} config: AgentOSConfigInput<undefined> = options.config ?? {}
) { ) {
return yield* Effect.try({ return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }), catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
}); });
}), }),
}) })
); );
export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")( export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
function* (config?: AgentOSConfigInput<undefined>) { function* createAgentOsActorEffect(config?: AgentOSConfigInput<undefined>) {
const agentOs = yield* AgentOs; const agentOs = yield* AgentOs;
return yield* agentOs.createActor(config); return yield* agentOs.createActor(config);
} }

View File

@@ -0,0 +1,145 @@
import { Effect, Exit } from "effect";
import { describe, expect, test } from "vitest";
import {
decideGitLifecycle,
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
} from "./git";
const inspectionInput = {
branch: "work/issue-5",
diff: "diff --git a/file.ts b/file.ts",
status: " M file.ts",
};
const inspection = await Effect.runPromise(
inspectGitWorkspace(inspectionInput)
);
describe("Git lifecycle primitives", () => {
test("parses workspace status and treats a diff as publishable changes", () => {
expect(inspection).toMatchObject({
branch: "work/issue-5",
hasChanges: true,
status: [{ index: " ", path: "file.ts", worktree: "M" }],
});
});
test("requires verification before any publish action", async () => {
const result = await Effect.runPromiseExit(
decideGitLifecycle({
baseBranch: "main",
inspection,
pushed: false,
verification: "not-run",
})
);
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const failure = result.cause as unknown as {
readonly reasons: readonly [
{ readonly error: { readonly reason: string } },
];
};
expect(failure.reasons[0]?.error.reason).toBe("VerificationRequired");
}
});
test("never publishes the default branch", async () => {
const result = await Effect.runPromiseExit(
decideGitLifecycle({
baseBranch: "work/issue-5",
inspection,
pushed: false,
verification: "passed",
})
);
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const failure = result.cause as unknown as {
readonly reasons: readonly [
{ readonly error: { readonly reason: string } },
];
};
expect(failure.reasons[0]?.error.reason).toBe("InvalidBranch");
}
});
test("moves verified changes through commit, push, PR, then manual merge", async () => {
const commit = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
inspection,
pushed: false,
verification: "passed",
})
);
expect(commit).toEqual({ decision: "commit", status: "committed" });
const push = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pushed: false,
verification: "passed",
})
);
expect(push).toEqual({ decision: "push", status: "pushed" });
const pr = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pushed: true,
verification: "passed",
})
);
expect(pr).toEqual({
decision: "create_pull_request",
status: "pull_request_open",
});
const merge = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
},
pushed: true,
verification: "passed",
})
);
expect(merge).toEqual({
decision: "manual_merge",
status: "pull_request_open",
});
});
test("validates PR metadata and keeps merge manual", async () => {
const metadata = await Effect.runPromise(
validatePullRequestMetadata({
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
})
);
expect(metadata.status).toBe("open");
expect(makeCommitMessage(5, "Ship Git lifecycle")).toBe(
"feat(issue-5): Ship Git lifecycle"
);
});
});

View File

@@ -0,0 +1,250 @@
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const GitBranchName = MeaningfulString.pipe(
Schema.check(
Schema.makeFilter(
(value) =>
!value.startsWith("-") &&
!value.includes("..") &&
!value.includes(" ") &&
!value.includes("~") &&
!value.includes("^") &&
!value.includes(":") &&
!value.includes("\\"),
{ expected: "a valid Git branch name" }
)
)
);
export type GitBranchName = typeof GitBranchName.Type;
export const GitStatusEntry = Schema.Struct({
index: Schema.String,
path: MeaningfulString,
raw: Schema.String,
worktree: Schema.String,
});
export type GitStatusEntry = typeof GitStatusEntry.Type;
export const GitWorkspaceInspection = Schema.Struct({
branch: GitBranchName,
diff: Schema.String,
hasChanges: Schema.Boolean,
status: Schema.Array(GitStatusEntry),
});
export type GitWorkspaceInspection = typeof GitWorkspaceInspection.Type;
export const GitVerification = Schema.Literals(["passed", "failed", "not-run"]);
export type GitVerification = typeof GitVerification.Type;
export const PullRequestStatus = Schema.Literals(["open", "closed", "merged"]);
export type PullRequestStatus = typeof PullRequestStatus.Type;
export const PullRequestMetadata = Schema.Struct({
baseBranch: GitBranchName,
branch: GitBranchName,
number: Schema.Int.check(Schema.isGreaterThan(0)),
status: PullRequestStatus,
url: Schema.String.check(
Schema.makeFilter(
(value) => {
try {
return new URL(value).protocol.startsWith("http");
} catch {
return false;
}
},
{ expected: "a valid HTTP pull request URL" }
)
),
});
export type PullRequestMetadata = typeof PullRequestMetadata.Type;
export const GitLifecycleStatus = Schema.Literals([
"no_changes",
"committed",
"pushed",
"pull_request_open",
"failed",
]);
export type GitLifecycleStatus = typeof GitLifecycleStatus.Type;
export const GitLifecycleResult = Schema.Struct({
baseBranch: GitBranchName,
branch: GitBranchName,
commitSha: Schema.optional(MeaningfulString),
pullRequest: Schema.optional(PullRequestMetadata),
status: GitLifecycleStatus,
});
export type GitLifecycleResult = typeof GitLifecycleResult.Type;
export const GitLifecycleDecision = Schema.Literals([
"finish",
"commit",
"push",
"create_pull_request",
"manual_merge",
]);
export type GitLifecycleDecision = typeof GitLifecycleDecision.Type;
export const GitLifecycleDecisionInput = Schema.Struct({
baseBranch: GitBranchName,
commitSha: Schema.optional(MeaningfulString),
inspection: GitWorkspaceInspection,
pullRequest: Schema.optional(PullRequestMetadata),
pushed: Schema.Boolean,
verification: GitVerification,
});
export type GitLifecycleDecisionInput = typeof GitLifecycleDecisionInput.Type;
export const GitLifecycleDecisionResult = Schema.Struct({
decision: GitLifecycleDecision,
status: GitLifecycleStatus,
});
export type GitLifecycleDecisionResult = typeof GitLifecycleDecisionResult.Type;
export const GitLifecycleErrorReason = Schema.Literals([
"Authentication",
"CommandFailed",
"InvalidInput",
"InvalidBranch",
"NoChanges",
"InvalidPullRequest",
"RemoteRejected",
"RequestFailed",
"VerificationRequired",
]);
export type GitLifecycleErrorReason = typeof GitLifecycleErrorReason.Type;
export class GitLifecycleError extends Schema.TaggedErrorClass<GitLifecycleError>()(
"GitLifecycleError",
{
message: Schema.String,
reason: GitLifecycleErrorReason,
}
) {}
const invalidInput = (message: string) =>
new GitLifecycleError({ message, reason: "InvalidInput" });
export const parseGitStatus = (rawStatus: string): GitStatusEntry[] =>
rawStatus
.split("\n")
.filter((line) => line.length > 0)
.map((raw) => ({
index: raw.slice(0, 1),
path: raw.slice(3).trim(),
raw,
worktree: raw.slice(1, 2),
}));
export const inspectGitWorkspace = Effect.fn("Git.inspectWorkspace")(
function* inspectGitWorkspace(input: unknown) {
const decoded = yield* Schema.decodeUnknownEffect(
Schema.Struct({
branch: GitBranchName,
diff: Schema.String,
status: Schema.String,
})
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
const status = parseGitStatus(decoded.status);
return {
branch: decoded.branch,
diff: decoded.diff,
hasChanges: status.length > 0 || decoded.diff.length > 0,
status,
} satisfies GitWorkspaceInspection;
}
);
export const decideGitLifecycle = Effect.fn("Git.decideLifecycle")(
function* decideGitLifecycle(input: unknown) {
const decoded = yield* Schema.decodeUnknownEffect(
GitLifecycleDecisionInput
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
if (decoded.inspection.branch === decoded.baseBranch) {
return yield* Effect.fail(
new GitLifecycleError({
message: `Refusing to publish the default branch ${decoded.baseBranch}`,
reason: "InvalidBranch",
})
);
}
if (decoded.verification !== "passed") {
return yield* Effect.fail(
new GitLifecycleError({
message: "Verified changes are required before publishing",
reason: "VerificationRequired",
})
);
}
if (!decoded.inspection.hasChanges && decoded.commitSha === undefined) {
return {
decision: "finish" as const,
status: "no_changes" as const,
} satisfies GitLifecycleDecisionResult;
}
if (decoded.commitSha === undefined) {
return {
decision: "commit" as const,
status: "committed" as const,
} satisfies GitLifecycleDecisionResult;
}
if (!decoded.pushed) {
return {
decision: "push" as const,
status: "pushed" as const,
} satisfies GitLifecycleDecisionResult;
}
if (decoded.pullRequest === undefined) {
return {
decision: "create_pull_request" as const,
status: "pull_request_open" as const,
} satisfies GitLifecycleDecisionResult;
}
return {
decision: "manual_merge" as const,
status: "pull_request_open" as const,
} satisfies GitLifecycleDecisionResult;
}
);
export const validatePullRequestMetadata = Effect.fn(
"Git.validatePullRequestMetadata"
)(function* validatePullRequestMetadata(input: unknown) {
const metadata = yield* Schema.decodeUnknownEffect(PullRequestMetadata)(
input
).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
if (metadata.branch === metadata.baseBranch) {
return yield* Effect.fail(
new GitLifecycleError({
message: "A pull request must target a different branch",
reason: "InvalidPullRequest",
})
);
}
return metadata;
});
export const makeCommitMessage = (
issueNumber: number,
title: string
): string => {
const normalizedTitle = title.replaceAll(/\s+/gu, " ").trim();
return `feat(issue-${issueNumber}): ${normalizedTitle}`.slice(0, 120);
};

View File

@@ -1,4 +1,9 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules. // oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os"; export * from "./agent-os";
export * from "./git";
export * from "./project"; export * from "./project";
export * from "./project-issue";
export * from "./project-workspace";
export * from "./project-work";
export * from "./signal"; export * from "./signal";
export * from "./smoke";

View File

@@ -0,0 +1,77 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
decodeProjectIssueRequest,
ProjectIssueTransitionError,
ProjectIssueValidationError,
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
} from "./project-issue";
describe("project issue primitives", () => {
it("normalizes an explicit project request", () => {
const request = Effect.runSync(
decodeProjectIssueRequest({
body: " Investigate the staging failure. ",
kind: "explicit",
projectId: "project-1",
title: " Staging failure ",
})
);
expect(request).toEqual({
body: "Investigate the staging failure.",
kind: "explicit",
projectId: "project-1",
title: "Staging failure",
});
});
it("rejects invalid issue drafts before persistence", () => {
const error = Effect.runSync(
Effect.flip(
validateProjectIssueDraft({
body: "too short",
title: "No",
})
)
);
expect(error).toBeInstanceOf(ProjectIssueValidationError);
expect(error.reason).toBe("TitleTooShort");
});
it("projects a project Signal into the existing issue shape", () => {
const draft = Effect.runSync(
projectIssueDraftFromSignal({
problemStatement: {
constraints: ["Do not change production"],
desiredOutcome: "Staging deploys successfully",
summary: "The deploy fails during DNS resolution",
title: "Fix staging deploy DNS failure",
},
signalId: "signal-1",
})
);
expect(draft.title).toBe("Fix staging deploy DNS failure");
expect(draft.body).toContain("The deploy fails during DNS resolution");
expect(draft.body).toContain("Source Signal: signal-1");
});
it("queues retryable issue states and preserves active states", () => {
expect(Effect.runSync(queueProjectIssue("open"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("failed"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("needs-input"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("working"))).toBe("working");
});
it("does not reopen completed issues", () => {
const error = Effect.runSync(Effect.flip(queueProjectIssue("completed")));
expect(error).toBeInstanceOf(ProjectIssueTransitionError);
expect(error.reason).toBe("CompletedIssue");
});
});

View File

@@ -0,0 +1,265 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect";
import { ProblemStatement, ProjectId, SignalId } from "./signal.js";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const ProjectIssueId = MeaningfulString.pipe(
Schema.brand("ProjectIssueId")
);
export type ProjectIssueId = typeof ProjectIssueId.Type;
export const ProjectIssueStatus = Schema.Literals([
"open",
"queued",
"working",
"needs-input",
"completed",
"failed",
]);
export type ProjectIssueStatus = typeof ProjectIssueStatus.Type;
const ProjectIssueDraftInput = Schema.Struct({
body: Schema.String,
title: Schema.String,
});
export const ProjectIssueDraft = Schema.Struct({
body: MeaningfulString,
title: MeaningfulString,
});
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
export const ExplicitProjectIssueRequest = Schema.Struct({
body: Schema.String,
kind: Schema.Literal("explicit"),
projectId: ProjectId,
title: Schema.String,
});
export const SignalProjectIssueRequest = Schema.Struct({
kind: Schema.Literal("signal"),
signalId: SignalId,
});
export const ProjectIssueRequest = Schema.Union([
ExplicitProjectIssueRequest,
SignalProjectIssueRequest,
]);
export type ProjectIssueRequest = typeof ProjectIssueRequest.Type;
export const ProjectIssueDispatchInput = Schema.Struct({
issueId: ProjectIssueId,
kind: Schema.Literal("project.issue.started"),
projectId: ProjectId,
});
export type ProjectIssueDispatchInput = typeof ProjectIssueDispatchInput.Type;
export const ProjectIssueRequestResult = Schema.Struct({
acceptedAt: MeaningfulString,
dispatchId: MeaningfulString,
issueId: ProjectIssueId,
projectId: ProjectId,
status: Schema.Literals(["queued", "working"]),
});
export type ProjectIssueRequestResult = typeof ProjectIssueRequestResult.Type;
export const ProjectIssueValidationReason = Schema.Literals([
"InvalidInput",
"TitleTooShort",
"TitleTooLong",
"BodyTooShort",
"BodyTooLong",
]);
export type ProjectIssueValidationReason =
typeof ProjectIssueValidationReason.Type;
export class ProjectIssueValidationError extends Schema.TaggedErrorClass<ProjectIssueValidationError>()(
"ProjectIssueValidationError",
{
message: Schema.String,
reason: ProjectIssueValidationReason,
}
) {}
export const ProjectIssueRequestErrorReason = Schema.Literals([
"InvalidInput",
"InvalidProjectId",
"InvalidSignalId",
]);
export type ProjectIssueRequestErrorReason =
typeof ProjectIssueRequestErrorReason.Type;
export class ProjectIssueRequestError extends Schema.TaggedErrorClass<ProjectIssueRequestError>()(
"ProjectIssueRequestError",
{
message: Schema.String,
reason: ProjectIssueRequestErrorReason,
}
) {}
export const ProjectIssueTransitionReason = Schema.Literals([
"InvalidStatus",
"CompletedIssue",
]);
export type ProjectIssueTransitionReason =
typeof ProjectIssueTransitionReason.Type;
export class ProjectIssueTransitionError extends Schema.TaggedErrorClass<ProjectIssueTransitionError>()(
"ProjectIssueTransitionError",
{
message: Schema.String,
reason: ProjectIssueTransitionReason,
}
) {}
export const validateProjectIssueDraft = (
input: unknown
): Effect.Effect<ProjectIssueDraft, ProjectIssueValidationError> =>
Effect.gen(function* validateDraft() {
const decoded = yield* Schema.decodeUnknownEffect(ProjectIssueDraftInput)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueValidationError({
message: "Issue title and body are required",
reason: "InvalidInput",
})
)
);
const title = decoded.title.trim();
const body = decoded.body.trim();
if (title.length < 3) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooShort",
})
);
}
if (title.length > 160) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooLong",
})
);
}
if (body.length < 10) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooShort",
})
);
}
if (body.length > 10_000) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooLong",
})
);
}
return { body, title };
});
export const decodeProjectIssueRequest = (
input: unknown
): Effect.Effect<
ProjectIssueRequest,
ProjectIssueRequestError | ProjectIssueValidationError
> =>
Effect.gen(function* decodeRequest() {
const request = yield* Schema.decodeUnknownEffect(ProjectIssueRequest)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message:
"Use a signal request or an explicit request with projectId, title, and body",
reason: "InvalidInput",
})
)
);
if (request.kind === "explicit") {
const draft = yield* validateProjectIssueDraft(request);
return { ...request, ...draft };
}
return request;
});
export const projectIssueDraftFromSignal = (input: unknown) =>
Effect.gen(function* draftFromSignal() {
const source = yield* Schema.decodeUnknownEffect(
Schema.Struct({
problemStatement: ProblemStatement,
signalId: SignalId,
})
)(input).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message: "Project Signal is missing a valid problem statement",
reason: "InvalidSignalId",
})
)
);
const constraints =
source.problemStatement.constraints.length === 0
? "None"
: source.problemStatement.constraints
.map((constraint) => `- ${constraint}`)
.join("\n");
return yield* validateProjectIssueDraft({
body: [
source.problemStatement.summary,
`Desired outcome: ${source.problemStatement.desiredOutcome}`,
`Constraints:\n${constraints}`,
`Source Signal: ${source.signalId}`,
].join("\n\n"),
title: source.problemStatement.title,
});
});
export const queueProjectIssue = (
input: unknown
): Effect.Effect<"queued" | "working", ProjectIssueTransitionError> =>
Effect.gen(function* queueIssue() {
const status = yield* Schema.decodeUnknownEffect(ProjectIssueStatus)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueTransitionError({
message: "Issue has an invalid status",
reason: "InvalidStatus",
})
)
);
if (status === "queued" || status === "working") {
return status;
}
if (status === "completed") {
return yield* Effect.fail(
new ProjectIssueTransitionError({
message: "Completed issues cannot be queued again",
reason: "CompletedIssue",
})
);
}
return "queued" as const;
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "./project-work";
describe("project work state mapping", () => {
test("maps issue and run statuses to the same product language", () => {
expect(getProjectWorkStatus("working")).toEqual({
label: "In progress",
phase: "active",
verification: "running",
});
expect(getProjectWorkStatus("needs-input")).toEqual({
label: "Needs your input",
phase: "waiting",
verification: "blocked",
});
});
test("keeps progress monotonic through the normal loop", () => {
expect(getProjectWorkProgress("open")).toBeLessThan(
getProjectWorkProgress("queued")
);
expect(getProjectWorkProgress("queued")).toBeLessThan(
getProjectWorkProgress("working")
);
expect(getProjectWorkProgress("working")).toBeLessThan(
getProjectWorkProgress("completed")
);
});
test("maps each state to the next product action", () => {
expect(getProjectWorkNextAction("open")).toBe("Start the project manager");
expect(getProjectWorkNextAction("failed")).toBe(
"Review the failure and retry"
);
expect(getProjectWorkNextAction("completed")).toBe(
"Review the pull request"
);
});
});

View File

@@ -0,0 +1,131 @@
import type { ProjectIssueStatus } from "./project-issue.js";
export const PROJECT_ISSUE_STATUSES = [
"open",
"queued",
"working",
"needs-input",
"completed",
"failed",
] as const;
export const PROJECT_RUN_STATUSES = [
"ready",
"queued",
"working",
"needs-input",
"completed",
"failed",
] as const;
export type ProjectRunStatus = (typeof PROJECT_RUN_STATUSES)[number];
export type ProjectWorkPhase =
| "captured"
| "queued"
| "active"
| "waiting"
| "completed"
| "failed";
export type ProjectVerificationStatus =
| "not-started"
| "running"
| "blocked"
| "passed"
| "failed";
export interface ProjectWorkStatus {
readonly label: string;
readonly phase: ProjectWorkPhase;
readonly verification: ProjectVerificationStatus;
}
const NEXT_ACTION_MAP: Readonly<
Record<ProjectIssueStatus | ProjectRunStatus, string>
> = {
completed: "Review the pull request",
failed: "Review the failure and retry",
"needs-input": "Resolve the open question",
open: "Start the project manager",
queued: "Watch the run progress",
ready: "Watch the run progress",
working: "Wait for verification",
};
const STATUS_MAP: Readonly<
Record<ProjectIssueStatus | ProjectRunStatus, ProjectWorkStatus>
> = {
completed: {
label: "Completed",
phase: "completed",
verification: "passed",
},
failed: {
label: "Failed",
phase: "failed",
verification: "failed",
},
"needs-input": {
label: "Needs your input",
phase: "waiting",
verification: "blocked",
},
open: {
label: "Ready to start",
phase: "captured",
verification: "not-started",
},
queued: {
label: "Queued",
phase: "queued",
verification: "not-started",
},
ready: {
label: "Ready",
phase: "queued",
verification: "not-started",
},
working: {
label: "In progress",
phase: "active",
verification: "running",
},
};
export const getProjectWorkStatus = (
status: ProjectIssueStatus | ProjectRunStatus
): ProjectWorkStatus => STATUS_MAP[status];
export const getProjectWorkProgress = (
status: ProjectIssueStatus | ProjectRunStatus
): number => {
switch (status) {
case "completed": {
return 100;
}
case "failed": {
return 58;
}
case "needs-input": {
return 78;
}
case "working": {
return 68;
}
case "queued":
case "ready": {
return 24;
}
case "open": {
return 8;
}
default: {
return 0;
}
}
};
export const getProjectWorkNextAction = (
status: ProjectIssueStatus | ProjectRunStatus
): string => NEXT_ACTION_MAP[status];

View File

@@ -0,0 +1,141 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
decodeIssueWorkspaceResult,
makeIssueWorkspacePlan,
transitionWorkspaceRun,
WorkspaceExecutionError,
WorkspaceStateError,
WorkspaceValidationError,
} from "./project-workspace";
const makeInput = () => ({
artifacts: [
{
content: "# Work\n",
path: "work.md",
},
],
branchName: undefined,
checkoutPath: undefined,
contextFiles: [
{
content: "# Project\n",
kind: "readme" as const,
path: "README.md",
},
],
defaultBranch: "main",
issueBody: "Make the project run in an isolated checkout.",
issueId: "projectIssues|abc123",
issueNumber: 7,
issueTitle: "Use a real project checkout",
sourceUrl: "https://git.example.test/puter/project",
});
const validationFailure = (input: unknown): WorkspaceValidationError => {
const error = Effect.runSync(Effect.flip(makeIssueWorkspacePlan(input)));
expect(error).toBeInstanceOf(WorkspaceValidationError);
return error;
};
describe("project workspace primitives", () => {
it("plans a deterministic checkout and keeps control files outside the repo", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const secondPlan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
expect(plan.branchName).toBe("work/issue-7-abc123");
expect(plan.checkoutPath).toBe("/workspace/repository");
expect(plan).toEqual(secondPlan);
expect(plan.operations).toEqual(
expect.arrayContaining([
{
_tag: "WriteFile",
content: expect.any(String),
path: "/workspace/control/issue.md",
},
{
_tag: "WriteFile",
content: "# Project\n",
path: "/workspace/control/context/README.md",
},
{
_tag: "WriteFile",
content: "# Work\n",
path: "/workspace/control/artifacts/work.md",
},
])
);
expect(
plan.operations.some(
(operation) =>
operation._tag === "Exec" && operation.command.includes("git clone")
)
).toBe(true);
});
it("rejects unsafe sources and control-file paths", () => {
const missingSource = { ...makeInput(), sourceUrl: undefined };
expect(validationFailure(missingSource).reason).toBe("MissingSource");
const invalidSource = makeInput();
invalidSource.sourceUrl = "ssh://git@example.test/project";
expect(validationFailure(invalidSource).reason).toBe("InvalidSourceUrl");
const invalidPath = makeInput();
const [artifact] = invalidPath.artifacts;
if (!artifact) {
throw new Error("test artifact missing");
}
artifact.path = "../work.md";
expect(validationFailure(invalidPath).reason).toBe("InvalidPath");
});
it("rejects a checkout result that does not prove the planned branch", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const error = Effect.runSync(
Effect.flip(
decodeIssueWorkspaceResult({
command: {
exitCode: 0,
stderr: "",
stdout: "work/issue-7-other deadbeef\n",
},
plan,
})
)
);
expect(error).toBeInstanceOf(WorkspaceExecutionError);
expect(error.reason).toBe("InvalidResult");
});
it("accepts the planned checkout result and enforces run transitions", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const result = Effect.runSync(
decodeIssueWorkspaceResult({
command: {
exitCode: 0,
stderr: "",
stdout: `${plan.branchName} deadbeef1234567\n`,
},
plan,
})
);
expect(result).toMatchObject({
branchName: plan.branchName,
checkoutPath: "/workspace/repository",
headSha: "deadbeef1234567",
});
expect(
Effect.runSync(transitionWorkspaceRun({ from: "queued", to: "working" }))
).toBe("working");
const error = Effect.runSync(
Effect.flip(transitionWorkspaceRun({ from: "completed", to: "working" }))
);
expect(error).toBeInstanceOf(WorkspaceStateError);
});
});

View File

@@ -0,0 +1,436 @@
/* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */
import { Effect, Schema } from "effect";
import { CONTEXT_KINDS } from "./project.js";
export type { ContextKind } from "./project.js";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const PositiveInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
const WORKSPACE_PATH = "/workspace";
const CHECKOUT_PATH = "/workspace/repository";
const CONTROL_PATH = "/workspace/control";
const isSafeRelativePath = (value: string): boolean => {
if (value.length === 0 || value.startsWith("/") || value.includes("\\")) {
return false;
}
return !value
.split("/")
.some((segment) => segment === "" || segment === "." || segment === "..");
};
const isValidGitRef = (value: string): boolean =>
value.length > 0 &&
!value.startsWith("/") &&
!value.endsWith("/") &&
!value.startsWith(".") &&
!value.endsWith(".") &&
!value.includes("..") &&
!value.includes("//") &&
!value.includes("@{") &&
![...value].some((character) => character < " ") &&
!/[ ~^:?*[\\]/u.test(value);
const isPublicGitUrl = (value: string): boolean => {
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
url.hostname.length > 0 &&
url.pathname !== "/" &&
url.username.length === 0 &&
url.password.length === 0 &&
url.search.length === 0 &&
url.hash.length === 0
);
} catch {
return false;
}
};
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", "'\"'\"'")}'`;
const slugIdentity = (value: string): string => {
const parts = value
.toLowerCase()
.split(/[^a-z0-9]+/gu)
.filter(Boolean);
let result = "";
for (const part of parts) {
result = part;
}
return result.replaceAll(/^-+|-+$/gu, "").slice(-24) || "issue";
};
export const WorkspaceContextFile = Schema.Struct({
content: Schema.String,
kind: Schema.Literals([...CONTEXT_KINDS]),
path: MeaningfulString,
});
export type WorkspaceContextFile = typeof WorkspaceContextFile.Type;
export const WorkspaceArtifactFile = Schema.Struct({
content: Schema.String,
path: MeaningfulString,
});
export type WorkspaceArtifactFile = typeof WorkspaceArtifactFile.Type;
export const IssueWorkspaceInput = Schema.Struct({
artifacts: Schema.Array(WorkspaceArtifactFile),
branchName: Schema.UndefinedOr(MeaningfulString),
checkoutPath: Schema.UndefinedOr(MeaningfulString),
contextFiles: Schema.Array(WorkspaceContextFile),
defaultBranch: Schema.UndefinedOr(MeaningfulString),
issueBody: MeaningfulString,
issueId: MeaningfulString,
issueNumber: PositiveInteger,
issueTitle: MeaningfulString,
sourceUrl: Schema.UndefinedOr(MeaningfulString),
});
export type IssueWorkspaceInput = typeof IssueWorkspaceInput.Type;
export const WorkspaceExecOperation = Schema.Struct({
_tag: Schema.Literal("Exec"),
command: MeaningfulString,
cwd: MeaningfulString,
timeoutMs: PositiveInteger,
});
export const WorkspaceMkdirOperation = Schema.Struct({
_tag: Schema.Literal("Mkdir"),
path: MeaningfulString,
});
export const WorkspaceWriteFileOperation = Schema.Struct({
_tag: Schema.Literal("WriteFile"),
content: Schema.String,
path: MeaningfulString,
});
export const IssueWorkspaceOperation = Schema.Union([
WorkspaceExecOperation,
WorkspaceMkdirOperation,
WorkspaceWriteFileOperation,
]);
export type IssueWorkspaceOperation = typeof IssueWorkspaceOperation.Type;
export const IssueWorkspacePlan = Schema.Struct({
artifactsRoot: MeaningfulString,
baseBranch: MeaningfulString,
branchName: MeaningfulString,
checkoutPath: MeaningfulString,
contextRoot: MeaningfulString,
issuePath: MeaningfulString,
operations: Schema.Array(IssueWorkspaceOperation),
sourceUrl: MeaningfulString,
});
export type IssueWorkspacePlan = typeof IssueWorkspacePlan.Type;
export const IssueWorkspaceCommandResult = Schema.Struct({
exitCode: Schema.Int,
stderr: Schema.String,
stdout: Schema.String,
});
export type IssueWorkspaceCommandResult =
typeof IssueWorkspaceCommandResult.Type;
export const IssueWorkspaceResult = Schema.Struct({
baseBranch: MeaningfulString,
branchName: MeaningfulString,
checkoutPath: MeaningfulString,
headSha: MeaningfulString,
sourceUrl: MeaningfulString,
});
export type IssueWorkspaceResult = typeof IssueWorkspaceResult.Type;
export const WorkspaceValidationReason = Schema.Literals([
"MissingSource",
"InvalidInput",
"InvalidSourceUrl",
"InvalidBranchName",
"InvalidPath",
"DuplicatePath",
]);
export type WorkspaceValidationReason = typeof WorkspaceValidationReason.Type;
export class WorkspaceValidationError extends Schema.TaggedErrorClass<WorkspaceValidationError>()(
"WorkspaceValidationError",
{
message: Schema.String,
reason: WorkspaceValidationReason,
}
) {}
export const WorkspaceExecutionReason = Schema.Literals([
"CommandFailed",
"InvalidResult",
]);
export type WorkspaceExecutionReason = typeof WorkspaceExecutionReason.Type;
export class WorkspaceExecutionError extends Schema.TaggedErrorClass<WorkspaceExecutionError>()(
"WorkspaceExecutionError",
{
message: Schema.String,
reason: WorkspaceExecutionReason,
}
) {}
export const WorkspaceRunStatus = Schema.Literals([
"ready",
"queued",
"working",
"needs-input",
"completed",
"failed",
]);
export type WorkspaceRunStatus = typeof WorkspaceRunStatus.Type;
export class WorkspaceStateError extends Schema.TaggedErrorClass<WorkspaceStateError>()(
"WorkspaceStateError",
{
from: WorkspaceRunStatus,
message: Schema.String,
to: WorkspaceRunStatus,
}
) {}
const validationError = (message: string, reason: WorkspaceValidationReason) =>
new WorkspaceValidationError({ message, reason });
const requireWorkspaceSource = (sourceUrl: string | undefined) => {
if (!sourceUrl || sourceUrl.trim().length === 0) {
return Effect.fail(
validationError("Project source is not connected", "MissingSource")
);
}
if (!isPublicGitUrl(sourceUrl)) {
return Effect.fail(
validationError(
"Issue workspace source must be a public http(s) Git URL",
"InvalidSourceUrl"
)
);
}
return Effect.succeed(sourceUrl);
};
const validateFiles = (
files: readonly { readonly path: string }[],
root: string
): Effect.Effect<void, WorkspaceValidationError> =>
Effect.gen(function* validateWorkspaceFiles() {
const paths = new Set<string>();
for (const file of files) {
if (!isSafeRelativePath(file.path)) {
return yield* Effect.fail(
validationError(
`Invalid workspace file path: ${file.path}`,
"InvalidPath"
)
);
}
const path = `${root}/${file.path}`;
if (paths.has(path)) {
return yield* Effect.fail(
validationError(
`Duplicate workspace file path: ${path}`,
"DuplicatePath"
)
);
}
paths.add(path);
}
});
const makeBranchName = (issueNumber: number, issueId: string): string =>
`work/issue-${issueNumber}-${slugIdentity(issueId)}`;
const makeCheckoutCommand = (input: {
readonly baseBranch: string;
readonly branchName: string;
readonly checkoutPath: string;
readonly sourceUrl: string;
}): string => {
const checkout = shellQuote(input.checkoutPath);
const baseBranch = shellQuote(input.baseBranch);
const branch = shellQuote(input.branchName);
const originBaseBranch = shellQuote(`origin/${input.baseBranch}`);
const source = shellQuote(input.sourceUrl);
return [
"set -eu",
`mkdir -p ${shellQuote(WORKSPACE_PATH)}`,
`if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`,
`if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`,
`printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`,
].join(" && ");
};
export const makeIssueWorkspacePlan = Effect.fn("ProjectWorkspace.makePlan")(
function* makeIssueWorkspacePlan(rawInput: unknown) {
const input: IssueWorkspaceInput = yield* Schema.decodeUnknownEffect(
IssueWorkspaceInput
)(rawInput).pipe(
Effect.mapError(() =>
validationError("Invalid issue workspace input", "InvalidInput")
)
);
const sourceUrl = yield* requireWorkspaceSource(input.sourceUrl);
const baseBranch = input.defaultBranch?.trim() || "main";
const branchName =
input.branchName?.trim() ||
makeBranchName(input.issueNumber, input.issueId);
const checkoutPath = input.checkoutPath?.trim() || CHECKOUT_PATH;
if (!isValidGitRef(baseBranch) || !isValidGitRef(branchName)) {
return yield* Effect.fail(
validationError(
"Issue workspace branch name is not a valid Git ref",
"InvalidBranchName"
)
);
}
if (
checkoutPath !== CHECKOUT_PATH ||
!checkoutPath.startsWith(`${WORKSPACE_PATH}/`) ||
checkoutPath.includes("..")
) {
return yield* Effect.fail(
validationError(
"Issue workspace checkout path must be /workspace/repository",
"InvalidPath"
)
);
}
const contextRoot = `${CONTROL_PATH}/context`;
const artifactsRoot = `${CONTROL_PATH}/artifacts`;
yield* validateFiles(input.contextFiles, contextRoot);
yield* validateFiles(input.artifacts, artifactsRoot);
const operations: IssueWorkspaceOperation[] = [
{ _tag: "Mkdir", path: CONTROL_PATH },
{
_tag: "Exec",
command: makeCheckoutCommand({
baseBranch,
branchName,
checkoutPath,
sourceUrl,
}),
cwd: WORKSPACE_PATH,
timeoutMs: 300_000,
},
{ _tag: "Mkdir", path: contextRoot },
{ _tag: "Mkdir", path: artifactsRoot },
{
_tag: "WriteFile",
content: `# Issue ${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}\n`,
path: `${CONTROL_PATH}/issue.md`,
},
...input.contextFiles.map((file) => ({
_tag: "WriteFile" as const,
content: file.content,
path: `${contextRoot}/${file.path}`,
})),
...input.artifacts.map((file) => ({
_tag: "WriteFile" as const,
content: file.content,
path: `${artifactsRoot}/${file.path}`,
})),
];
return {
artifactsRoot,
baseBranch,
branchName,
checkoutPath,
contextRoot,
issuePath: `${CONTROL_PATH}/issue.md`,
operations,
sourceUrl,
};
}
);
export const decodeIssueWorkspaceResult = Effect.fn(
"ProjectWorkspace.decodeResult"
)(function* decodeIssueWorkspaceResult(input: {
readonly command: IssueWorkspaceCommandResult;
readonly plan: IssueWorkspacePlan;
}) {
if (input.command.exitCode !== 0) {
return yield* Effect.fail(
new WorkspaceExecutionError({
message:
input.command.stderr || "Issue workspace checkout command failed",
reason: "CommandFailed",
})
);
}
const [branchName, headSha] = input.command.stdout.trim().split(/\s+/u);
if (
branchName !== input.plan.branchName ||
!headSha ||
!/^[0-9a-f]{7,64}$/u.test(headSha)
) {
return yield* Effect.fail(
new WorkspaceExecutionError({
message:
"Issue workspace checkout command returned invalid branch metadata",
reason: "InvalidResult",
})
);
}
return {
baseBranch: input.plan.baseBranch,
branchName,
checkoutPath: input.plan.checkoutPath,
headSha,
sourceUrl: input.plan.sourceUrl,
};
});
const allowedTransitions: Readonly<
Record<WorkspaceRunStatus, readonly WorkspaceRunStatus[]>
> = {
completed: [],
failed: [],
"needs-input": ["queued", "working", "failed"],
queued: ["working", "completed", "failed"],
ready: ["queued", "working", "failed"],
working: ["needs-input", "completed", "failed"],
};
export const transitionWorkspaceRun = Effect.fn(
"ProjectWorkspace.transitionRun"
)(function* transitionWorkspaceRun(input: {
readonly from: WorkspaceRunStatus;
readonly to: WorkspaceRunStatus;
}) {
if (
input.from === input.to ||
allowedTransitions[input.from].includes(input.to)
) {
return input.to;
}
return yield* Effect.fail(
new WorkspaceStateError({
from: input.from,
message: `Cannot transition issue workspace run from ${input.from} to ${input.to}`,
to: input.to,
})
);
});

View File

@@ -0,0 +1,164 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
SmokeContractError,
advanceSmokeLoop,
decodeSmokeChatText,
decodeSmokeModelCatalog,
decodeSmokePlan,
decodeSmokeReview,
decodeSmokeWorkerReport,
initialSmokeLoopState,
redactSmokeText,
selectSmokeModelRoles,
validateSmokeCapabilities,
} from "./smoke";
const validRoleSelection = () => ({
plannerReviewerModel: "glm-5.2",
plannerReviewerProvider: "cheaptricks",
workerModel: "minimax-m3",
workerProvider: "cheaptricks",
workerToolCallMode: "serial" as const,
});
const run = <A, E>(effect: Effect.Effect<A, E>): A => Effect.runSync(effect);
describe("smoke primitives", () => {
it("selects the planner/reviewer and serial M3 worker roles", () => {
const roles = run(selectSmokeModelRoles(validRoleSelection()));
expect(roles.worker).toMatchObject({
model: "minimax-m3",
role: "worker",
toolCallMode: "serial",
});
expect(roles.plannerReviewer).toMatchObject({
model: "glm-5.2",
role: "planner-reviewer",
});
});
it("rejects a non-M3 worker or non-GLM planner", () => {
const worker = validRoleSelection();
worker.workerModel = "glm-5.2";
const workerError = run(Effect.flip(selectSmokeModelRoles(worker)));
expect(workerError).toBeInstanceOf(SmokeContractError);
expect(workerError.reason).toBe("InvalidModelRole");
const planner = validRoleSelection();
planner.plannerReviewerModel = "minimax-m3";
const plannerError = run(Effect.flip(selectSmokeModelRoles(planner)));
expect(plannerError.reason).toBe("InvalidModelRole");
});
it("advances only through the serial smoke loop", () => {
let state = initialSmokeLoopState();
state = run(advanceSmokeLoop(state, "preflight-passed"));
state = run(advanceSmokeLoop(state, "plan-validated"));
state = run(advanceSmokeLoop(state, "worker-succeeded"));
state = run(advanceSmokeLoop(state, "review-approved"));
expect(state.phase).toBe("completed");
expect(state.history).toEqual([
"preflight",
"planning",
"worker-running",
"reviewing",
"completed",
]);
});
it("turns contract failures into a terminal state", () => {
const state = run(
advanceSmokeLoop(
initialSmokeLoopState(),
"contract-failed",
"daemon is offline"
)
);
expect(state.phase).toBe("failed");
expect(state.lastError).toBe("daemon is offline");
});
it("rejects out-of-order loop transitions", () => {
const error = run(
Effect.flip(advanceSmokeLoop(initialSmokeLoopState(), "review-approved"))
);
expect(error).toBeInstanceOf(SmokeContractError);
expect(error.reason).toBe("InvalidTransition");
});
it("validates model reports at the primitive boundary", () => {
const plan = run(
decodeSmokePlan({
acceptanceCriteria: ["The button renders"],
nonGoals: [],
steps: ["Add the button", "Run the web check"],
summary: "Add a small button to the disposable project",
})
);
const worker = run(
decodeSmokeWorkerReport({
status: "completed",
summary: "Changed the project and ran the targeted check.",
})
);
const review = run(
decodeSmokeReview({
approved: true,
findings: [],
summary: "The change matches the acceptance criteria.",
})
);
expect(plan.steps).toHaveLength(2);
expect(worker.status).toBe("completed");
expect(review.approved).toBe(true);
});
it("rejects empty or malformed model output", () => {
const empty = run(
Effect.flip(
decodeSmokeChatText({ choices: [{ message: { content: " " } }] })
)
);
expect(empty.reason).toBe("InvalidModelResponse");
const malformed = run(Effect.flip(decodeSmokeReview({ approved: true })));
expect(malformed.reason).toBe("InvalidReport");
});
it("validates the canonical CPA model catalog", () => {
const catalog = run(
decodeSmokeModelCatalog({
data: [{ id: "minimax-m3" }, { id: "glm-5.2" }],
})
);
expect(catalog.data.map(({ id }) => id)).toEqual(["minimax-m3", "glm-5.2"]);
});
it("fails a capability report when any required check fails", () => {
const error = run(
Effect.flip(
validateSmokeCapabilities([
{ id: "web", message: "ready", passed: true },
{ id: "daemon", message: "daemon is offline", passed: false },
])
)
);
expect(error.reason).toBe("InvalidReport");
expect(error.message).toContain("daemon is offline");
});
it("redacts secrets without changing unrelated text", () => {
expect(
redactSmokeText("status token=secret-key endpoint=ok", ["secret-key"])
).toBe("status token=[REDACTED] endpoint=ok");
});
});

View File

@@ -0,0 +1,347 @@
/* eslint-disable max-classes-per-file -- the smoke contract keeps its schemas, errors, and transitions together. */
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const SmokePhase = Schema.Literals([
"preflight",
"planning",
"worker-running",
"reviewing",
"completed",
"failed",
]);
export type SmokePhase = typeof SmokePhase.Type;
const SmokeTransition = Schema.Literals([
"preflight-passed",
"plan-validated",
"worker-succeeded",
"review-approved",
"contract-failed",
"runtime-failed",
]);
type SmokeTransition = typeof SmokeTransition.Type;
export const SmokeRole = Schema.Literals(["planner-reviewer", "worker"]);
export type SmokeRole = typeof SmokeRole.Type;
const SmokeToolCallMode = Schema.Literal("serial");
export const SmokeRoleSelectionInput = Schema.Struct({
plannerReviewerModel: MeaningfulString,
plannerReviewerProvider: MeaningfulString,
workerModel: MeaningfulString,
workerProvider: MeaningfulString,
workerToolCallMode: SmokeToolCallMode,
});
export type SmokeRoleSelectionInput = typeof SmokeRoleSelectionInput.Type;
export const SmokeModelRole = Schema.Struct({
model: MeaningfulString,
provider: MeaningfulString,
role: SmokeRole,
toolCallMode: Schema.Literal("serial"),
});
export type SmokeModelRole = typeof SmokeModelRole.Type;
export const SmokeModelRoles = Schema.Struct({
plannerReviewer: SmokeModelRole,
worker: SmokeModelRole,
});
export type SmokeModelRoles = typeof SmokeModelRoles.Type;
export const SmokeContractReason = Schema.Literals([
"InvalidInput",
"InvalidModelRole",
"InvalidTransition",
"InvalidModelResponse",
"InvalidReport",
]);
export type SmokeContractReason = typeof SmokeContractReason.Type;
export class SmokeContractError extends Schema.TaggedErrorClass<SmokeContractError>()(
"SmokeContractError",
{
message: Schema.String,
reason: SmokeContractReason,
}
) {}
export const selectSmokeModelRoles = Effect.fn("Smoke.selectModelRoles")(
function* selectSmokeModelRoles(input: unknown) {
const selection: SmokeRoleSelectionInput =
yield* Schema.decodeUnknownEffect(SmokeRoleSelectionInput)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidInput",
})
)
);
if (selection.workerModel !== "minimax-m3") {
return yield* Effect.fail(
new SmokeContractError({
message: "The smoke worker must use minimax-m3",
reason: "InvalidModelRole",
})
);
}
if (selection.plannerReviewerModel !== "glm-5.2") {
return yield* Effect.fail(
new SmokeContractError({
message: "The smoke planner/reviewer must use glm-5.2",
reason: "InvalidModelRole",
})
);
}
return {
plannerReviewer: {
model: selection.plannerReviewerModel,
provider: selection.plannerReviewerProvider,
role: "planner-reviewer" as const,
toolCallMode: "serial" as const,
},
worker: {
model: selection.workerModel,
provider: selection.workerProvider,
role: "worker" as const,
toolCallMode: "serial" as const,
},
} satisfies SmokeModelRoles;
}
);
export const SmokePlan = Schema.Struct({
acceptanceCriteria: Schema.NonEmptyArray(MeaningfulString),
nonGoals: Schema.Array(MeaningfulString),
steps: Schema.NonEmptyArray(MeaningfulString),
summary: MeaningfulString,
});
export type SmokePlan = typeof SmokePlan.Type;
export const SmokeWorkerReport = Schema.Struct({
status: Schema.Literals(["completed", "blocked", "failed"]),
summary: MeaningfulString,
});
export type SmokeWorkerReport = typeof SmokeWorkerReport.Type;
export const SmokeReview = Schema.Struct({
approved: Schema.Boolean,
findings: Schema.Array(MeaningfulString),
summary: MeaningfulString,
});
export type SmokeReview = typeof SmokeReview.Type;
export const SmokeChatResponse = Schema.Struct({
choices: Schema.NonEmptyArray(
Schema.Struct({
message: Schema.Struct({ content: Schema.String }),
})
),
});
export const SmokeModelCatalog = Schema.Struct({
data: Schema.Array(Schema.Struct({ id: MeaningfulString })),
});
export type SmokeModelCatalog = typeof SmokeModelCatalog.Type;
export const decodeSmokeModelCatalog = Effect.fn("Smoke.decodeModelCatalog")(
function* decodeSmokeModelCatalog(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeModelCatalog)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidModelResponse",
})
)
);
}
);
export const decodeSmokeChatText = Effect.fn("Smoke.decodeChatText")(
function* decodeSmokeChatText(input: unknown) {
const response = yield* Schema.decodeUnknownEffect(SmokeChatResponse)(
input
).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidModelResponse",
})
)
);
const text = response.choices[0].message.content;
if (text.trim().length === 0) {
return yield* Effect.fail(
new SmokeContractError({
message: "The model returned an empty response",
reason: "InvalidModelResponse",
})
);
}
return text;
}
);
export const decodeSmokePlan = Effect.fn("Smoke.decodePlan")(
function* decodeSmokePlan(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokePlan)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const decodeSmokeWorkerReport = Effect.fn("Smoke.decodeWorkerReport")(
function* decodeSmokeWorkerReport(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeWorkerReport)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const decodeSmokeReview = Effect.fn("Smoke.decodeReview")(
function* decodeSmokeReview(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeReview)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const SmokeCapabilityCheck = Schema.Struct({
id: MeaningfulString,
message: MeaningfulString,
passed: Schema.Boolean,
});
export type SmokeCapabilityCheck = typeof SmokeCapabilityCheck.Type;
export const validateSmokeCapabilities = Effect.fn(
"Smoke.validateCapabilities"
)(function* validateSmokeCapabilities(input: unknown) {
const checks = yield* Schema.decodeUnknownEffect(
Schema.NonEmptyArray(SmokeCapabilityCheck)
)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidInput",
})
)
);
const failed = checks.filter((check) => !check.passed);
if (failed.length > 0) {
return yield* Effect.fail(
new SmokeContractError({
message: failed
.map((check) => `${check.id}: ${check.message}`)
.join("; "),
reason: "InvalidReport",
})
);
}
return checks;
});
export const SmokeLoopState = Schema.Struct({
history: Schema.Array(SmokePhase),
lastError: Schema.NullOr(Schema.String),
phase: SmokePhase,
});
export type SmokeLoopState = typeof SmokeLoopState.Type;
export const initialSmokeLoopState = (): SmokeLoopState => ({
history: ["preflight"],
lastError: null,
phase: "preflight",
});
const nextPhase: Readonly<
Record<SmokePhase, Partial<Record<SmokeTransition, SmokePhase>>>
> = {
completed: {},
failed: {},
planning: {
"plan-validated": "worker-running",
"runtime-failed": "failed",
},
preflight: {
"contract-failed": "failed",
"preflight-passed": "planning",
"runtime-failed": "failed",
},
reviewing: {
"contract-failed": "failed",
"review-approved": "completed",
"runtime-failed": "failed",
},
"worker-running": {
"contract-failed": "failed",
"runtime-failed": "failed",
"worker-succeeded": "reviewing",
},
};
export const advanceSmokeLoop = Effect.fn("Smoke.advanceLoop")(
function* advanceSmokeLoop(
state: SmokeLoopState,
transition: SmokeTransition,
error?: string
) {
const phase = nextPhase[state.phase][transition];
if (phase === undefined) {
return yield* Effect.fail(
new SmokeContractError({
message: `Cannot apply ${transition} while smoke loop is ${state.phase}`,
reason: "InvalidTransition",
})
);
}
return {
history: [...state.history, phase],
lastError: phase === "failed" ? (error ?? "Smoke loop failed") : null,
phase,
} satisfies SmokeLoopState;
}
);
export const redactSmokeText = (
text: string,
secrets: readonly string[]
): string => {
let redacted = text;
for (const secret of secrets) {
if (secret.length > 0) {
redacted = redacted.replaceAll(secret, "[REDACTED]");
}
}
return redacted;
};

View File

@@ -16,6 +16,11 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.0", "@shadcn/react": "^0.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"ai": "^7.0.35",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "catalog:", "lucide-react": "catalog:",
@@ -23,9 +28,12 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"shadcn": "^4.12.0", "shadcn": "^4.12.0",
"shiki": "^4.3.1",
"sonner": "catalog:", "sonner": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "catalog:", "tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",

View File

@@ -0,0 +1,525 @@
"use client";
import { Button } from "@code/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@code/ui/components/select";
import { cn } from "@code/ui/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline.
const hasFontStyle = (fontStyle: number | undefined, flag: number) =>
Math.floor((fontStyle ?? 0) / flag) % 2 === 1;
const isItalic = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 1);
const isBold = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 2);
const isUnderline = (fontStyle: number | undefined) =>
hasFontStyle(fontStyle, 4);
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
export const highlightCode = async (
code: string,
language: BundledLanguage
): Promise<TokenizedCode> => {
const tokensCacheKey = getTokensCacheKey(code, language);
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
const highlighter = await getHighlighter(language);
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
tokensCache.set(tokensCacheKey, tokenized);
return tokenized;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
const cacheKey = getTokensCacheKey(code, language);
// Synchronous cache lookup avoids a loading flash after the first highlight.
const syncTokens = useMemo(
() => tokensCache.get(cacheKey) ?? rawTokens,
[cacheKey, rawTokens]
);
const [asyncResult, setAsyncResult] = useState<{
cacheKey: string;
tokens: TokenizedCode;
} | null>(null);
useEffect(() => {
let cancelled = false;
const loadHighlightedTokens = async () => {
try {
const result = await highlightCode(code, language);
if (!cancelled) {
setAsyncResult({ cacheKey, tokens: result });
}
} catch (error) {
console.error("Failed to highlight code:", error);
}
};
void loadHighlightedTokens();
return () => {
cancelled = true;
};
}, [cacheKey, code, language]);
const tokenized =
asyncResult?.cacheKey === cacheKey ? asyncResult.tokens : syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;

View File

@@ -0,0 +1,168 @@
"use client";
import { Button } from "@code/ui/components/button";
import { cn } from "@code/ui/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};

View File

@@ -0,0 +1,357 @@
"use client";
import { Button } from "@code/ui/components/button";
import { ButtonGroup, ButtonGroupText } from "@code/ui/components/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@code/ui/components/tooltip";
import { cn } from "@code/ui/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,173 @@
"use client";
import { Badge } from "@code/ui/components/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@code/ui/components/collapsible";
import { cn } from "@code/ui/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@code/ui/lib/utils";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
},
},
}
);
const Badge = ({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) =>
useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
});
export { Badge, badgeVariants };

View File

@@ -0,0 +1,82 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Separator } from "@code/ui/components/separator";
import { cn } from "@code/ui/lib/utils";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
const buttonGroupVariants = cva(
"m-0 flex min-w-0 w-fit items-stretch rounded-none border-0 p-0 *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-none [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
defaultVariants: {
orientation: "horizontal",
},
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
}
);
const ButtonGroup = ({
className,
orientation,
...props
}: React.ComponentProps<"fieldset"> &
VariantProps<typeof buttonGroupVariants>) => (
<fieldset
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
const ButtonGroupText = ({
className,
render,
...props
}: useRender.ComponentProps<"div">) =>
useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-none border bg-muted px-2.5 text-xs font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
});
const ButtonGroupSeparator = ({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) => (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
);
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -0,0 +1,17 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
const Collapsible = ({ ...props }: CollapsiblePrimitive.Root.Props) => (
<CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
);
const CollapsibleTrigger = ({
...props
}: CollapsiblePrimitive.Trigger.Props) => (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
const CollapsibleContent = ({ ...props }: CollapsiblePrimitive.Panel.Props) => (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,363 @@
import { cn } from "@code/ui/lib/utils";
import { ArrowUp, GripVertical, LoaderCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
const MobileViewport = ({ className, ...props }: ComponentProps<"div">) => (
<div
data-slot="mobile-viewport"
className={cn(
"relative mx-auto h-[100dvh] min-h-0 w-full max-w-[390px] overflow-hidden bg-background text-foreground sm:border-x sm:border-border/70",
className
)}
{...props}
/>
);
const MobileChatMark = ({ className, ...props }: ComponentProps<"span">) => (
<span
aria-hidden="true"
data-slot="mobile-chat-mark"
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f1f1ef] text-[#171716]",
className
)}
{...props}
>
<GripVertical className="size-3.5" strokeWidth={2.2} />
</span>
);
interface MobileChatHeaderProps extends Omit<
ComponentProps<"header">,
"children"
> {
active?: boolean;
label?: string;
statusLabel: string;
}
const MobileChatHeader = ({
active = false,
className,
label = "Zopu",
statusLabel,
...props
}: MobileChatHeaderProps) => (
<header
data-slot="mobile-chat-header"
className={cn(
"flex h-[78px] shrink-0 items-center border-b border-[#e9e9e7] bg-[#fefefe] px-4",
className
)}
{...props}
>
<div className="flex w-full items-center gap-3">
<MobileChatMark className="size-7 rounded-[7px]" />
<h1 className="text-[17px] leading-none font-semibold tracking-[-0.015em] text-[#171716]">
{label}
</h1>
<span
aria-hidden="true"
className={cn(
"ml-auto size-1.5 rounded-full bg-[#b9bbb8]",
active && "bg-[#17c777]"
)}
/>
<span className="sr-only" aria-live="polite">
{statusLabel}
</span>
</div>
</header>
);
interface MobileChatMessageProps extends Omit<
ComponentProps<"article">,
"role"
> {
sender: "assistant" | "user";
}
const MobileChatMessage = ({
className,
sender,
...props
}: MobileChatMessageProps) => (
<article
data-sender={sender}
data-slot="mobile-chat-message"
className={cn(
"flex w-full min-w-0",
sender === "user" ? "justify-end" : "justify-start",
className
)}
{...props}
/>
);
interface MobileChatBubbleProps extends Omit<ComponentProps<"div">, "role"> {
sender: "assistant" | "user";
}
const MobileChatBubble = ({
className,
sender,
...props
}: MobileChatBubbleProps) => (
<div
data-sender={sender}
data-slot="mobile-chat-bubble"
className={cn(
"min-w-0 text-[15px] tracking-[-0.012em] wrap-break-word",
sender === "user"
? "max-w-[260px] rounded-[20px] bg-[#0d0d0c] px-[14px] py-[11px] leading-[18px] text-[#fafafa]"
: "w-full leading-[18px] text-[#232321]",
className
)}
{...props}
/>
);
interface MobileChatAssistantLabelProps extends ComponentProps<"div"> {
label?: string;
state?: ReactNode;
}
const MobileChatAssistantLabel = ({
className,
label = "Zopu",
state,
...props
}: MobileChatAssistantLabelProps) => (
<div
data-slot="mobile-chat-assistant-label"
className={cn(
"mb-3 flex items-center gap-2 text-[14px] leading-5 text-[#6f706e]",
className
)}
{...props}
>
<MobileChatMark className="size-5 rounded-[5px] [&_svg]:size-3" />
<span>{label}</span>
{state}
</div>
);
interface MobileChatToolCallProps extends ComponentProps<"div"> {
detail?: ReactNode;
icon?: ReactNode;
status: string;
tone?: "error" | "neutral" | "success";
toolName: string;
}
const MobileChatToolCall = ({
children,
className,
detail,
icon,
status,
tone = "neutral",
toolName,
...props
}: MobileChatToolCallProps) => {
let content: ReactNode;
if (children) {
content = <div className="mt-2 grid gap-2">{children}</div>;
} else if (detail) {
content = (
<div className="mt-2 flex items-start gap-2">
<MobileChatMark className="mt-0.5 size-4 rounded-[4px] [&_svg]:size-2.5" />
<p className="line-clamp-2 min-w-0 text-[11px] leading-[15px] text-[#555653]">
{detail}
</p>
</div>
);
}
return (
<div
data-slot="mobile-chat-tool-call"
className={cn(
"mb-3 overflow-hidden rounded-[16px] bg-[#f4f4f2] px-3 py-3 text-[#171716]",
className
)}
{...props}
>
<div className="flex min-h-5 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-3" strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 font-semibold">
{toolName}
</span>
<span
className={cn(
"rounded-full bg-[#e6e7e4] px-2 py-1 text-[9px] leading-none font-medium text-[#454643]",
tone === "success" && "bg-[#dff5e8] text-[#174b32]",
tone === "error" && "bg-[#f8dfdc] text-[#7b2821]"
)}
>
{status}
</span>
</div>
{content}
</div>
);
};
interface MobileChatToolResultProps extends ComponentProps<"div"> {
icon?: ReactNode;
source?: string;
title: string;
}
const MobileChatToolResult = ({
className,
icon,
source,
title,
...props
}: MobileChatToolResultProps) => (
<div
data-slot="mobile-chat-tool-result"
className={cn("flex min-w-0 items-start gap-2", className)}
{...props}
>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-2.5" strokeWidth={2.2} />}
</span>
<span className="min-w-0">
<span className="block truncate text-[11px] leading-[14px] font-medium text-[#292a28]">
{title}
</span>
{source ? (
<span className="block truncate text-[9px] leading-[12px] text-[#9a9b98]">
{source}
</span>
) : null}
</span>
</div>
);
interface MobileChatComposerDockProps extends ComponentProps<"div"> {
errorMessage?: string;
statusMessage?: ReactNode;
}
const MobileChatComposerDock = ({
children,
className,
errorMessage,
statusMessage,
...props
}: MobileChatComposerDockProps) => {
const message = errorMessage ?? statusMessage;
return (
<div
data-slot="mobile-chat-composer-dock"
className={cn(
"shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]",
className
)}
{...props}
>
{message ? (
<div
id={errorMessage ? "composer-error" : undefined}
className={cn(
"px-3 pt-2 text-center text-[10px] leading-4 text-[#777875]",
errorMessage && "text-destructive"
)}
>
{message}
</div>
) : null}
{children}
</div>
);
};
interface MobileChatComposerProps extends Omit<
ComponentProps<"form">,
"children"
> {
busy?: boolean;
canSend: boolean;
errorMessage?: string;
statusMessage?: ReactNode;
textareaProps: ComponentProps<"textarea">;
}
const MobileChatComposer = ({
busy = false,
canSend,
className,
errorMessage,
statusMessage,
textareaProps,
...props
}: MobileChatComposerProps) => {
const { className: textareaClassName, ...inputProps } = textareaProps;
return (
<MobileChatComposerDock
errorMessage={errorMessage}
statusMessage={statusMessage}
>
<form
aria-label="Send a message"
className={cn("w-full px-3 pt-[11px] pb-3", className)}
{...props}
>
<div className="flex items-end gap-2.5">
<textarea
aria-label="Message Zopu"
placeholder="Message Zopu..."
rows={1}
className={cn(
"min-h-10 max-h-28 flex-1 resize-none rounded-[20px] border-0 bg-[#f4f4f2] px-4 py-[10px] text-[15px] leading-5 text-[#232321] outline-none placeholder:text-center placeholder:text-[#b6b7b4] focus-visible:ring-2 focus-visible:ring-[#171716]/15 disabled:cursor-not-allowed disabled:opacity-60",
textareaClassName
)}
{...inputProps}
/>
<button
aria-label="Send message"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0c] text-[#fafafa] transition-transform active:scale-[0.96] disabled:bg-[#dededb] disabled:text-[#9b9c99]"
disabled={!canSend}
type="submit"
>
{busy ? (
<LoaderCircle className="size-[18px] animate-spin" />
) : (
<ArrowUp className="size-[18px]" strokeWidth={2.2} />
)}
</button>
</div>
</form>
</MobileChatComposerDock>
);
};
export {
MobileChatAssistantLabel,
MobileChatBubble,
MobileChatComposer,
MobileChatComposerDock,
MobileChatHeader,
MobileChatMark,
MobileChatMessage,
MobileChatToolResult,
MobileChatToolCall,
MobileViewport,
};
export type {
MobileChatAssistantLabelProps,
MobileChatBubbleProps,
MobileChatComposerProps,
MobileChatComposerDockProps,
MobileChatHeaderProps,
MobileChatMessageProps,
MobileChatToolCallProps,
MobileChatToolResultProps,
};

View File

@@ -0,0 +1,17 @@
export {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "./mobile-workspace/index";
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./mobile-workspace/index";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
export { MobileHomeScreen } from "./mobile-home-screen";
export { MobileWorkListScreen } from "./mobile-work-list-screen";
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./types";

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileAssistantChatScreen = (
props: MobileWorkspaceScreenProps
) => <MobileWorkspaceScreenRenderer {...props} variant="character-pass" />;

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileExpandedWorkScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="home-expanded-in-place" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileHomeScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="work-units-home" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileWorkListScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="vertical-work-stack" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileWorkUnitDetailScreen = (
props: MobileWorkspaceScreenProps
) => <MobileWorkspaceScreenRenderer {...props} variant="expanded-work-unit" />;

View File

@@ -0,0 +1,63 @@
export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
export interface MobileProjectView {
readonly connected: boolean;
readonly id: string;
readonly name: string;
}
export interface MobileWorkUnitView {
readonly artifactCount: number;
readonly code: string;
readonly id: string;
readonly canRetry: boolean;
readonly canStart: boolean;
readonly nextAction: string;
readonly pullRequestNumber?: number;
readonly progress: number;
readonly reviewUrl?: string;
readonly statusLabel: string;
readonly summary: string;
readonly title: string;
readonly tone: MobileWorkUnitTone;
readonly updatedLabel: string;
}
export interface MobileSignalView {
readonly desiredOutcome: string;
readonly id: string;
readonly projectId?: string;
readonly summary: string;
readonly title: string;
}
export interface MobileAssistantMessageView {
readonly id: string;
readonly role: "assistant" | "user";
readonly text: string;
}
export interface MobileAssistantView {
readonly isBusy: boolean;
readonly messages: readonly MobileAssistantMessageView[];
readonly statusLabel: string;
readonly statusTone: "amber" | "green" | "red";
}
export interface MobileWorkspaceView {
readonly activeCount: number;
readonly activityCount: number;
readonly artifactCount: number;
readonly assistant: MobileAssistantView;
readonly isLoading: boolean;
readonly latestSignal?: MobileSignalView;
readonly needsAttentionCount: number;
readonly organizationLabel: string;
readonly projects: readonly MobileProjectView[];
readonly projectName?: string;
readonly selectedProjectId?: string;
readonly selectedWorkUnit?: MobileWorkUnitView;
readonly shippedCount: number;
readonly totalCount: number;
readonly workUnits: readonly MobileWorkUnitView[];
}

View File

@@ -0,0 +1,16 @@
import type { MobileWorkspaceRendererProps } from "../mobile-workspace-screens";
export type MobileWorkspaceScreenProps = Omit<
MobileWorkspaceRendererProps,
"variant"
>;
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./models";

View File

@@ -0,0 +1,183 @@
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@code/ui/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
const Select = SelectPrimitive.Root;
const SelectGroup = ({ className, ...props }: SelectPrimitive.Group.Props) => (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props}
/>
);
const SelectValue = ({ className, ...props }: SelectPrimitive.Value.Props) => (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
const SelectTrigger = ({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) => (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
const SelectScrollUpButton = ({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) => (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
const SelectScrollDownButton = ({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) => (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
const SelectContent = ({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
const SelectLabel = ({
className,
...props
}: SelectPrimitive.GroupLabel.Props) => (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-2 text-xs text-muted-foreground", className)}
{...props}
/>
);
const SelectItem = ({
className,
children,
...props
}: SelectPrimitive.Item.Props) => (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
const SelectSeparator = ({
className,
...props
}: SelectPrimitive.Separator.Props) => (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 h-px bg-border", className)}
{...props}
/>
);
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -76,7 +76,7 @@
} }
@theme inline { @theme inline {
--font-sans: "Inter Variable", sans-serif; --font-sans: "Inter", sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);

829
scripts/zopu-smoke.ts Normal file
View File

@@ -0,0 +1,829 @@
import { parseArgs } from "node:util";
import { api } from "@code/backend/convex/_generated/api";
import { parseSmokeEnv } from "@code/env/smoke";
import type { SmokeEnv } from "@code/env/smoke";
import {
advanceSmokeLoop,
decodeSmokeChatText,
decodeSmokeModelCatalog,
decodeSmokePlan,
decodeSmokeReview,
decodeSmokeWorkerReport,
initialSmokeLoopState,
redactSmokeText,
selectSmokeModelRoles,
validateSmokeCapabilities,
} from "@code/primitives/smoke";
import type {
SmokeCapabilityCheck,
SmokeLoopState,
SmokeModelRoles,
} from "@code/primitives/smoke";
import { createFlueClient } from "@flue/sdk";
import { ConvexHttpClient } from "convex/browser";
import { Effect } from "effect";
const DEFAULT_TIMEOUT_MS = 120_000;
const REQUIRED_ARTIFACTS = [
"agent.md",
"work.md",
"steps.md",
"artifacts.md",
"signals.md",
"agent-manager.md",
"context.md",
"card.md",
] as const;
type SmokeStatus =
| "preflight-passed"
| "completed"
| "contract-blocked"
| "failed";
interface SmokeReport {
readonly checks: readonly SmokeCapabilityCheck[];
readonly featureRequest: string;
readonly issueId?: string;
readonly loop: SmokeLoopState;
readonly mode: "preflight" | "run";
readonly roles?: Pick<SmokeModelRoles, "plannerReviewer" | "worker">;
readonly review?: {
readonly approved: boolean;
readonly findingsCount: number;
};
readonly status: SmokeStatus;
readonly worker?: {
readonly status: "completed" | "blocked" | "failed";
};
}
interface ChatMessage {
readonly content: string;
readonly role: "system" | "user";
}
const { values, positionals } = parseArgs({
allowPositionals: true,
args: process.argv.slice(2),
options: {
help: { default: false, short: "h", type: "boolean" },
report: { type: "string" },
run: { default: false, type: "boolean" },
"timeout-ms": { default: String(DEFAULT_TIMEOUT_MS), type: "string" },
},
strict: true,
});
const usage = `Usage: bun run smoke:zopu [--run] [--report <path>] [--timeout-ms <ms>]
Without --run, the command only probes the local web/Convex/Flue/AgentOS and CPA contracts.
With --run, it creates one issue in the explicit smoke project, drives the worker, and sends the result to the GLM planner/reviewer.
Required environment:
ZOPU_SMOKE_ACCESS_TOKEN Better Auth/Convex bearer token
ZOPU_SMOKE_PROJECT_ID disposable/test project id
ZOPU_SMOKE_CPA_API_KEY CPA key (or AGENT_MODEL_API_KEY)
ZOPU_SMOKE_CPA_BASE_URL CPA OpenAI-compatible /v1 URL (or AGENT_MODEL_BASE_URL)
ZOPU_SMOKE_CONVEX_URL Convex URL (or CONVEX_URL)
ZOPU_SMOKE_DAEMON_ID online local daemon id (or DAEMON_ID)
Optional environment:
ZOPU_SMOKE_FEATURE_REQUEST override the tiny feature request
ZOPU_SMOKE_WEB_URL default: SITE_URL or http://localhost:5173
ZOPU_SMOKE_FLUE_URL default: VITE_FLUE_URL or http://localhost:3583
`;
if (values.help || positionals.length > 0) {
console.log(usage);
process.exit(positionals.length > 0 ? 1 : 0);
}
const runEffect = <A, E>(effect: Effect.Effect<A, E>): A =>
Effect.runSync(effect);
const errorMessage = (error: unknown, secrets: readonly string[]): string => {
const message = error instanceof Error ? error.message : String(error);
return redactSmokeText(message, secrets).replaceAll(/\s+/gu, " ").trim();
};
const withTimeout = (timeoutMs: number): AbortSignal =>
AbortSignal.timeout(timeoutMs);
const normalizeBaseUrl = (value: string): URL => {
const url = new URL(value);
if (!url.pathname.endsWith("/")) {
url.pathname = `${url.pathname}/`;
}
return url;
};
const endpoint = (baseUrl: URL, path: string): string =>
new URL(path, baseUrl).toString();
const addCheck = (
checks: SmokeCapabilityCheck[],
id: string,
passed: boolean,
message: string
) => {
checks.push({ id, message, passed });
};
const probeHttp = async (
checks: SmokeCapabilityCheck[],
id: string,
label: string,
url: string,
timeoutMs: number
): Promise<void> => {
try {
const response = await fetch(url, {
redirect: "manual",
signal: withTimeout(timeoutMs),
});
const passed = response.status >= 200 && response.status < 400;
addCheck(
checks,
id,
passed,
passed
? `${label} responded with HTTP ${response.status}`
: `${label} returned HTTP ${response.status}`
);
} catch (error) {
addCheck(
checks,
id,
false,
`${label} is unreachable: ${errorMessage(error, [])}`
);
}
};
const callOpenAiChat = async (input: {
readonly apiKey: string;
readonly baseUrl: URL;
readonly messages: readonly ChatMessage[];
readonly model: string;
readonly timeoutMs: number;
}): Promise<string> => {
const response = await fetch(endpoint(input.baseUrl, "chat/completions"), {
body: JSON.stringify({
max_tokens: 512,
messages: input.messages,
model: input.model,
temperature: 0,
}),
headers: {
Authorization: `Bearer ${input.apiKey}`,
"Content-Type": "application/json",
},
method: "POST",
signal: withTimeout(input.timeoutMs),
});
if (!response.ok) {
throw new Error(
`CPA ${input.model} completion returned HTTP ${response.status}`
);
}
const body = (await response.json()) as unknown;
return await Effect.runPromise(decodeSmokeChatText(body));
};
const parseJsonObject = (text: string): unknown => {
const withoutFence = text
.trim()
.replace(/^```(?:json)?\s*/iu, "")
.replace(/\s*```$/u, "");
const start = withoutFence.indexOf("{");
const end = withoutFence.lastIndexOf("}");
if (start === -1 || end <= start) {
throw new Error("model response did not contain a JSON object");
}
return JSON.parse(withoutFence.slice(start, end + 1)) as unknown;
};
const buildPlanPrompt = (featureRequest: string): readonly ChatMessage[] => [
{
content:
"You are the planner/reviewer for a disposable Zopu web-project smoke. Return JSON only with summary, steps, acceptanceCriteria, and nonGoals. Keep the plan tiny and testable.",
role: "system",
},
{ content: featureRequest, role: "user" },
];
const buildWorkerPrompt = (input: {
readonly featureRequest: string;
readonly plan: {
readonly acceptanceCriteria: readonly string[];
readonly nonGoals: readonly string[];
readonly steps: readonly string[];
readonly summary: string;
};
}): string => `Execute this one disposable web-project smoke issue.
Feature request: ${input.featureRequest}
Plan:
${JSON.stringify(input.plan)}
Rules:
- Work only in the bound project workspace.
- Use one tool call at a time; never issue parallel tool calls because this worker is minimax-m3.
- Inspect the bound repository before editing.
- Run the narrowest relevant test or check.
- Do not claim completion without observed changed files and command evidence.
- Return JSON only: {"status":"completed"|"blocked"|"failed","summary":"..."}`;
const buildReviewPrompt = (input: {
readonly featureRequest: string;
readonly plan: unknown;
readonly workerReport: unknown;
}): readonly ChatMessage[] => [
{
content:
"You are the independent GLM-5.2 planner/reviewer. Review the worker report against the feature request and plan. Return JSON only with approved, summary, and findings (an array of concise strings). Reject missing evidence or a non-completed worker.",
role: "system",
},
{
content: JSON.stringify({
featureRequest: input.featureRequest,
plan: input.plan,
workerReport: input.workerReport,
}),
role: "user",
},
];
const printReport = (report: SmokeReport): void => {
console.log(`ZOPU_SMOKE_${report.status.toUpperCase().replaceAll("-", "_")}`);
console.log(JSON.stringify(report, null, 2));
};
const writeReport = async (
path: string | undefined,
report: SmokeReport
): Promise<void> => {
if (path !== undefined) {
await Bun.write(path, `${JSON.stringify(report, null, 2)}\n`);
}
};
const waitForIssueStatus = async (
convex: ConvexHttpClient,
projectId: string,
issueId: string,
timeoutMs: number
): Promise<string | null> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between durable reads.
const issues = await convex.query(api.projectIssues.list, {
projectId: projectId as never,
});
const issue = issues.find((candidate) => candidate._id === issueId);
if (issue?.status === "completed" || issue?.status === "failed") {
return issue.status;
}
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between durable reads.
await Bun.sleep(500);
}
return null;
};
const probeModelRoles = (
env: SmokeEnv,
cpaBaseUrl: URL,
checks: SmokeCapabilityCheck[],
secrets: readonly string[]
): SmokeModelRoles | undefined => {
try {
const roles = runEffect(
selectSmokeModelRoles({
plannerReviewerModel: "glm-5.2",
plannerReviewerProvider: "cheaptricks",
workerModel: env.agentModelName ?? "",
workerProvider: env.agentModelProvider ?? "cheaptricks",
workerToolCallMode: "serial",
})
);
addCheck(
checks,
"model-roles",
true,
"worker=minimax-m3; planner-reviewer=glm-5.2"
);
const workerGatewayMatches =
env.agentModelBaseUrl !== undefined &&
normalizeBaseUrl(env.agentModelBaseUrl).toString() ===
cpaBaseUrl.toString();
addCheck(
checks,
"worker-gateway-config",
workerGatewayMatches,
workerGatewayMatches
? "the local worker points at the configured CPA base URL"
: "AGENT_MODEL_BASE_URL must match the CPA base URL; the harness will not rewrite it"
);
return roles;
} catch (error) {
addCheck(checks, "model-roles", false, errorMessage(error, secrets));
addCheck(
checks,
"worker-gateway-config",
false,
"worker gateway configuration could not be checked because the model role contract failed"
);
return undefined;
}
};
const probeControlPlane = async (input: {
readonly checks: SmokeCapabilityCheck[];
readonly convex: ConvexHttpClient;
readonly daemonId: string;
readonly projectId: string | undefined;
readonly secrets: readonly string[];
}): Promise<void> => {
const { checks, convex, daemonId, projectId, secrets } = input;
try {
const health = await convex.query(api.healthCheck.get, {});
addCheck(
checks,
"convex",
health === "OK",
health === "OK"
? "Convex health query returned OK"
: "Convex health query returned an unexpected value"
);
} catch (error) {
addCheck(
checks,
"convex",
false,
`Convex health query failed: ${errorMessage(error, secrets)}`
);
}
try {
const organization = await convex.query(api.organizations.getCurrent, {});
addCheck(
checks,
"organization",
organization !== null,
organization
? "authenticated personal organization is available"
: "no personal organization; sign in through the web app before running the smoke"
);
} catch (error) {
addCheck(
checks,
"organization",
false,
`organization query failed: ${errorMessage(error, secrets)}`
);
}
if (projectId === undefined) {
addCheck(
checks,
"project-contract",
false,
"ZOPU_SMOKE_PROJECT_ID is required and must point at a disposable/test project"
);
} else {
// eslint-disable-next-line no-use-before-define -- the project probe is a focused helper below the control-plane flow.
await probeProjectContract({ checks, convex, projectId, secrets });
}
try {
const daemon = await convex.query(api.daemons.get, { daemonId });
const online = daemon.presence?.status === "online";
addCheck(
checks,
"agentos-daemon",
online,
online
? `daemon ${daemonId} is online`
: `daemon ${daemonId} is not online; start the local AgentOS daemon without changing CPA`
);
} catch (error) {
addCheck(
checks,
"agentos-daemon",
false,
`daemon probe failed: ${errorMessage(error, secrets)}`
);
}
};
const probeProjectContract = async (input: {
readonly checks: SmokeCapabilityCheck[];
readonly convex: ConvexHttpClient;
readonly projectId: string;
readonly secrets: readonly string[];
}): Promise<void> => {
const { checks, convex, projectId, secrets } = input;
try {
const project = await convex.query(api.projects.get, {
projectId: projectId as never,
});
const hasSource = (project?.sources.length ?? 0) > 0;
let projectMessage =
"project has no connected repository source; land the project backend lane first";
if (project === null) {
projectMessage =
"project was not found or is not accessible to the supplied token";
} else if (hasSource) {
projectMessage = "project source is available";
}
addCheck(
checks,
"project-contract",
project !== null && hasSource,
projectMessage
);
const artifacts = await convex.query(api.projectArtifacts.list, {
projectId: projectId as never,
});
const paths = new Set(artifacts.map((artifact) => artifact.path));
const missing = REQUIRED_ARTIFACTS.filter((path) => !paths.has(path));
addCheck(
checks,
"agentos-workspace-contract",
missing.length === 0,
missing.length === 0
? "issue-scoped AgentOS artifact contract is present"
: `missing AgentOS artifacts: ${missing.join(", ")}; land the project loop lane first`
);
} catch (error) {
addCheck(
checks,
"project-contract",
false,
`project query failed: ${errorMessage(error, secrets)}`
);
addCheck(
checks,
"agentos-workspace-contract",
false,
"project artifacts could not be inspected"
);
}
};
const probeFlue = async (input: {
readonly accessToken: string;
readonly checks: SmokeCapabilityCheck[];
readonly flueUrl: string;
readonly secrets: readonly string[];
}): Promise<ReturnType<typeof createFlueClient> | undefined> => {
const { accessToken, checks, flueUrl, secrets } = input;
const flueClient = createFlueClient({
baseUrl: flueUrl,
headers: { Authorization: `Bearer ${accessToken}` },
});
try {
await flueClient.agents.history(
"project-manager",
"zopu-smoke-capability-probe"
);
addCheck(
checks,
"flue",
true,
"project-manager history endpoint is reachable"
);
return flueClient;
} catch (error) {
const message = errorMessage(error, secrets);
const streamMissing = message.includes("[stream_not_found]");
addCheck(
checks,
"flue",
streamMissing,
streamMissing
? "project-manager history route is reachable; the probe stream is absent as expected"
: `Flue project-manager endpoint failed: ${message}`
);
return streamMissing ? flueClient : undefined;
}
};
const probeGateway = async (input: {
readonly apiKey: string;
readonly baseUrl: URL;
readonly checks: SmokeCapabilityCheck[];
readonly secrets: readonly string[];
readonly timeoutMs: number;
}): Promise<void> => {
const { apiKey, baseUrl, checks, secrets, timeoutMs } = input;
try {
const response = await fetch(endpoint(baseUrl, "models"), {
headers: { Authorization: `Bearer ${apiKey}` },
signal: withTimeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`CPA models returned HTTP ${response.status}`);
}
const catalog = await Effect.runPromise(
decodeSmokeModelCatalog(await response.json())
);
const modelIds = new Set(catalog.data.map((entry) => entry.id));
const missing = ["minimax-m3", "glm-5.2"].filter(
(model) => !modelIds.has(model)
);
addCheck(
checks,
"cpa-model-catalog",
missing.length === 0,
missing.length === 0
? "CPA exposes minimax-m3 and glm-5.2"
: `CPA is missing canonical models: ${missing.join(", ")}`
);
} catch (error) {
addCheck(
checks,
"cpa-model-catalog",
false,
`CPA model probe failed: ${errorMessage(error, secrets)}`
);
}
for (const model of ["minimax-m3", "glm-5.2"] as const) {
try {
// oxlint-disable-next-line no-await-in-loop -- M3 must never be probed in parallel with another worker call.
const text = await callOpenAiChat({
apiKey,
baseUrl,
messages: [{ content: "Reply with exactly OK.", role: "user" }],
model,
timeoutMs,
});
addCheck(
checks,
`cpa-completion-${model}`,
text.trim().length > 0,
`${model} accepted a no-tools OpenAI-compatible completion`
);
} catch (error) {
addCheck(
checks,
`cpa-completion-${model}`,
false,
errorMessage(error, secrets)
);
}
}
};
const runSmoke = async (input: {
readonly baseReport: Omit<SmokeReport, "status">;
readonly convex: ConvexHttpClient;
readonly cpaBaseUrl: URL;
readonly env: SmokeEnv;
readonly flueClient: ReturnType<typeof createFlueClient>;
readonly loop: SmokeLoopState;
readonly roles: SmokeModelRoles;
readonly secrets: readonly string[];
readonly timeoutMs: number;
}): Promise<number> => {
const {
baseReport,
convex,
cpaBaseUrl,
env,
flueClient,
roles,
secrets,
timeoutMs,
} = input;
const { projectId } = env;
if (projectId === undefined) {
throw new Error("run requested without ZOPU_SMOKE_PROJECT_ID");
}
let { loop } = input;
let issueId: string | undefined;
let worker: SmokeReport["worker"];
let review: SmokeReport["review"];
try {
const planText = await callOpenAiChat({
apiKey: env.cpaApiKey,
baseUrl: cpaBaseUrl,
messages: buildPlanPrompt(env.featureRequest),
model: roles.plannerReviewer.model,
timeoutMs,
});
const plan = await Effect.runPromise(
decodeSmokePlan(parseJsonObject(planText))
);
loop = runEffect(advanceSmokeLoop(loop, "plan-validated"));
issueId = await convex.mutation(api.projectIssues.create, {
body: `${env.featureRequest}\n\nSmoke plan:\n${JSON.stringify(plan)}`,
projectId: projectId as never,
title: "Zopu smoke: tiny web feature",
});
await convex.mutation(api.projectIssues.begin, {
issueId: issueId as never,
});
const workerResult = await flueClient.agents.prompt(
"project-manager",
issueId,
{
message: buildWorkerPrompt({
featureRequest: env.featureRequest,
plan,
}),
signal: withTimeout(timeoutMs),
}
);
const workerReport = await Effect.runPromise(
decodeSmokeWorkerReport(parseJsonObject(workerResult.result.text))
);
worker = { status: workerReport.status };
if (workerReport.status !== "completed") {
throw new Error(
`worker returned ${workerReport.status}: ${workerReport.summary}`
);
}
loop = runEffect(advanceSmokeLoop(loop, "worker-succeeded"));
const issueStatus = await waitForIssueStatus(
convex,
projectId,
issueId,
timeoutMs
);
if (issueStatus !== "completed") {
throw new Error(
`worker reported completed but durable issue status is ${issueStatus ?? "missing"}`
);
}
const reviewText = await callOpenAiChat({
apiKey: env.cpaApiKey,
baseUrl: cpaBaseUrl,
messages: buildReviewPrompt({
featureRequest: env.featureRequest,
plan,
workerReport,
}),
model: roles.plannerReviewer.model,
timeoutMs,
});
const reviewReport = await Effect.runPromise(
decodeSmokeReview(parseJsonObject(reviewText))
);
review = {
approved: reviewReport.approved,
findingsCount: reviewReport.findings.length,
};
if (!reviewReport.approved) {
throw new Error(
`GLM review rejected the worker result: ${reviewReport.summary}`
);
}
loop = runEffect(advanceSmokeLoop(loop, "review-approved"));
} catch (error) {
const message = errorMessage(error, secrets);
if (issueId !== undefined) {
await convex.mutation(api.projectIssues.markDispatchFailed, {
error: message,
issueId: issueId as never,
});
}
loop = runEffect(advanceSmokeLoop(loop, "runtime-failed", message));
const report: SmokeReport = {
...baseReport,
issueId,
loop,
status: "failed",
...(review === undefined ? {} : { review }),
...(worker === undefined ? {} : { worker }),
};
await writeReport(values.report, report);
printReport(report);
return 1;
}
const report: SmokeReport = {
...baseReport,
issueId,
loop,
review,
status: "completed",
worker,
};
await writeReport(values.report, report);
printReport(report);
return 0;
};
const main = async (): Promise<number> => {
const timeoutMs = Number(values["timeout-ms"] ?? DEFAULT_TIMEOUT_MS);
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
console.error(
"ZOPU_SMOKE_CONTRACT_BLOCKED: --timeout-ms must be a positive integer"
);
return 2;
}
let env: SmokeEnv;
try {
env = parseSmokeEnv(process.env);
} catch (error) {
console.error(
`ZOPU_SMOKE_CONTRACT_BLOCKED: environment contract failed: ${errorMessage(error, [])}`
);
return 2;
}
const secrets = [env.accessToken, env.cpaApiKey];
const checks: SmokeCapabilityCheck[] = [];
const convex = new ConvexHttpClient(env.convexUrl);
convex.setAuth(env.accessToken);
const cpaBaseUrl = normalizeBaseUrl(env.cpaBaseUrl);
const roles = probeModelRoles(env, cpaBaseUrl, checks, secrets);
await probeHttp(checks, "web", "web app", env.webUrl, timeoutMs);
await probeControlPlane({
checks,
convex,
daemonId: env.daemonId,
projectId: env.projectId,
secrets,
});
const flueClient = await probeFlue({
accessToken: env.accessToken,
checks,
flueUrl: env.flueUrl,
secrets,
});
await probeGateway({
apiKey: env.cpaApiKey,
baseUrl: cpaBaseUrl,
checks,
secrets,
timeoutMs,
});
const initialLoop = initialSmokeLoopState();
let loop: SmokeLoopState;
try {
runEffect(validateSmokeCapabilities(checks));
loop = runEffect(advanceSmokeLoop(initialLoop, "preflight-passed"));
} catch (error) {
loop = runEffect(
advanceSmokeLoop(
initialLoop,
"contract-failed",
errorMessage(error, secrets)
)
);
}
const baseReport = {
checks,
featureRequest: redactSmokeText(env.featureRequest, secrets),
loop,
mode: values.run ? ("run" as const) : ("preflight" as const),
...(roles === undefined ? {} : { roles }),
};
if (loop.phase === "failed") {
const report = { ...baseReport, status: "contract-blocked" as const };
await writeReport(values.report, report);
printReport(report);
return 2;
}
if (!values.run) {
const report = { ...baseReport, status: "preflight-passed" as const };
await writeReport(values.report, report);
printReport(report);
return 0;
}
if (roles === undefined || flueClient === undefined) {
throw new Error("run prerequisites were not retained after preflight");
}
return await runSmoke({
baseReport,
convex,
cpaBaseUrl,
env,
flueClient,
loop,
roles,
secrets,
timeoutMs,
});
};
const exitCode = await main().catch((error: unknown) => {
console.error(`ZOPU_SMOKE_FAILED: ${errorMessage(error, [])}`);
return 1;
});
process.exitCode = exitCode;