integrate thin Zopu MVP lanes (#14) #16
@@ -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",
|
||||||
|
|||||||
@@ -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:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
102
apps/web/src/components/mobile-workspace/mobile-flow-page.tsx
Normal file
102
apps/web/src/components/mobile-workspace/mobile-flow-page.tsx
Normal 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
@@ -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());
|
||||||
|
|||||||
91
apps/web/src/hooks/use-mobile-project-workspace.ts
Normal file
91
apps/web/src/hooks/use-mobile-project-workspace.ts
Normal 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;
|
||||||
|
};
|
||||||
82
apps/web/src/hooks/use-mobile-workspace.ts
Normal file
82
apps/web/src/hooks/use-mobile-workspace.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
186
apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts
Normal file
186
apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
91
apps/web/src/lib/projects/project-evidence.test.ts
Normal file
91
apps/web/src/lib/projects/project-evidence.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
apps/web/src/lib/projects/project-evidence.ts
Normal file
315
apps/web/src/lib/projects/project-evidence.ts
Normal 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),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
12
apps/web/src/routes/app/mobile/chat/page.tsx
Normal file
12
apps/web/src/routes/app/mobile/chat/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
9
apps/web/src/routes/app/mobile/layout.tsx
Normal file
9
apps/web/src/routes/app/mobile/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export default function MobileProductLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh bg-[#11110f]">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/web/src/routes/app/mobile/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
15
apps/web/src/routes/app/mobile/work-list/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/work-list/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
15
apps/web/src/routes/app/mobile/work/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/work/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
123
apps/web/src/routes/app/todos/page.tsx
Normal file
123
apps/web/src/routes/app/todos/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
@@ -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" },
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
162
bun.lock
@@ -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
46
docs/SMOKE.md
Normal 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.
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
155
packages/agents/src/git/gitea.test.ts
Normal file
155
packages/agents/src/git/gitea.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
307
packages/agents/src/git/gitea.ts
Normal file
307
packages/agents/src/git/gitea.ts
Normal 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",
|
||||||
|
};
|
||||||
|
};
|
||||||
162
packages/agents/src/project-request.ts
Normal file
162
packages/agents/src/project-request.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
8
packages/backend/convex/_generated/api.d.ts
vendored
8
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
177
packages/backend/convex/projectIssueRequests.smoke.test.ts
Normal file
177
packages/backend/convex/projectIssueRequests.smoke.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
125
packages/backend/convex/publicGit.ts
Normal file
125
packages/backend/convex/publicGit.ts
Normal 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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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"),
|
||||||
|
|||||||
4
packages/env/package.json
vendored
4
packages/env/package.json
vendored
@@ -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:"
|
||||||
|
|||||||
2
packages/env/src/agent.ts
vendored
2
packages/env/src/agent.ts
vendored
@@ -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
46
packages/env/src/smoke.ts
vendored
Normal 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",
|
||||||
|
});
|
||||||
@@ -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:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
145
packages/primitives/src/git.test.ts
Normal file
145
packages/primitives/src/git.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
packages/primitives/src/git.ts
Normal file
250
packages/primitives/src/git.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
77
packages/primitives/src/project-issue.test.ts
Normal file
77
packages/primitives/src/project-issue.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
265
packages/primitives/src/project-issue.ts
Normal file
265
packages/primitives/src/project-issue.ts
Normal 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;
|
||||||
|
});
|
||||||
44
packages/primitives/src/project-work.test.ts
Normal file
44
packages/primitives/src/project-work.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
packages/primitives/src/project-work.ts
Normal file
131
packages/primitives/src/project-work.ts
Normal 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];
|
||||||
141
packages/primitives/src/project-workspace.test.ts
Normal file
141
packages/primitives/src/project-workspace.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
436
packages/primitives/src/project-workspace.ts
Normal file
436
packages/primitives/src/project-workspace.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
164
packages/primitives/src/smoke.test.ts
Normal file
164
packages/primitives/src/smoke.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
347
packages/primitives/src/smoke.ts
Normal file
347
packages/primitives/src/smoke.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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:*",
|
||||||
|
|||||||
525
packages/ui/src/components/ai-elements/code-block.tsx
Normal file
525
packages/ui/src/components/ai-elements/code-block.tsx
Normal 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} />;
|
||||||
168
packages/ui/src/components/ai-elements/conversation.tsx
Normal file
168
packages/ui/src/components/ai-elements/conversation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
357
packages/ui/src/components/ai-elements/message.tsx
Normal file
357
packages/ui/src/components/ai-elements/message.tsx
Normal 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>
|
||||||
|
);
|
||||||
173
packages/ui/src/components/ai-elements/tool.tsx
Normal file
173
packages/ui/src/components/ai-elements/tool.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
packages/ui/src/components/badge.tsx
Normal file
51
packages/ui/src/components/badge.tsx
Normal 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 };
|
||||||
82
packages/ui/src/components/button-group.tsx
Normal file
82
packages/ui/src/components/button-group.tsx
Normal 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,
|
||||||
|
};
|
||||||
17
packages/ui/src/components/collapsible.tsx
Normal file
17
packages/ui/src/components/collapsible.tsx
Normal 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 };
|
||||||
363
packages/ui/src/components/mobile-chat.tsx
Normal file
363
packages/ui/src/components/mobile-chat.tsx
Normal 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,
|
||||||
|
};
|
||||||
17
packages/ui/src/components/mobile-product.tsx
Normal file
17
packages/ui/src/components/mobile-product.tsx
Normal 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";
|
||||||
1504
packages/ui/src/components/mobile-workspace-screens.tsx
Normal file
1504
packages/ui/src/components/mobile-workspace-screens.tsx
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/ui/src/components/mobile-workspace/index.ts
Normal file
15
packages/ui/src/components/mobile-workspace/index.ts
Normal 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";
|
||||||
@@ -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" />;
|
||||||
@@ -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" />
|
||||||
|
);
|
||||||
@@ -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" />
|
||||||
|
);
|
||||||
@@ -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" />
|
||||||
|
);
|
||||||
@@ -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" />;
|
||||||
63
packages/ui/src/components/mobile-workspace/models.ts
Normal file
63
packages/ui/src/components/mobile-workspace/models.ts
Normal 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[];
|
||||||
|
}
|
||||||
16
packages/ui/src/components/mobile-workspace/types.ts
Normal file
16
packages/ui/src/components/mobile-workspace/types.ts
Normal 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";
|
||||||
183
packages/ui/src/components/select.tsx
Normal file
183
packages/ui/src/components/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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
829
scripts/zopu-smoke.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user