Compare commits
44 Commits
feat/auth-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ffa1cfc7c | ||
|
|
18eb150d7d | ||
|
|
e1b0b731e0 | ||
|
|
d428a2492b | ||
|
|
fe0fd9b16c | ||
|
|
fc1fcf5d44 | ||
|
|
3ae72864bd | ||
|
|
0d5d54caa8 | ||
|
|
830bcc4756 | ||
|
|
0e56a462cd | ||
|
|
9fb293a539 | ||
|
|
062c00f53c | ||
|
|
a7e70c9b2a | ||
|
|
526ed59776 | ||
|
|
fd3980c6bf | ||
|
|
d8a4bbe804 | ||
|
|
9eb6bcd25f | ||
|
|
a907539810 | ||
|
|
601aca73c2 | ||
|
|
ffecff3857 | ||
|
|
0d7162544b | ||
|
|
092a9793ea | ||
|
|
5ee0a8d50e | ||
|
|
420676f2d7 | ||
|
|
24d82e2a06 | ||
|
|
d47fa0e96a | ||
|
|
1e7c893985 | ||
|
|
f9ebcb4a01 | ||
|
|
dceaa2b417 | ||
|
|
a4f121e190 | ||
|
|
4edd456b5b | ||
|
|
4d9f6da41b | ||
|
|
a53029cf7a | ||
|
|
eede4c10ba | ||
|
|
35169672e1 | ||
|
|
359d9e2285 | ||
|
|
05a3baaac3 | ||
|
|
4cc40cb2e4 | ||
|
|
814df02be9 | ||
|
|
d7f6cbdcdc | ||
|
|
a8d946b6a9 | ||
| 0e32c35515 | |||
|
|
005b26fa32 | ||
| cb7484912c |
@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
|
||||
NATIVE_APP_URL=code://
|
||||
|
||||
# Browser and native public endpoints
|
||||
VITE_AUTH_URL=http://localhost:5173
|
||||
VITE_CONVEX_URL=https://example.convex.cloud
|
||||
VITE_CONVEX_SITE_URL=https://example.convex.site
|
||||
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
|
||||
VITE_FLUE_URL=http://localhost:3583
|
||||
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
||||
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
||||
|
||||
@@ -19,10 +17,13 @@ DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
# RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
||||
|
||||
# Flue persistence adapter
|
||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||
FLUE_URL=http://localhost:3583
|
||||
|
||||
# Agent model provider
|
||||
AGENT_MODEL_PROVIDER=xiaomi
|
||||
|
||||
36
apps/web/Dockerfile
Normal file
36
apps/web/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
ARG VITE_AUTH_URL
|
||||
ARG VITE_CONVEX_URL
|
||||
ENV VITE_AUTH_URL=$VITE_AUTH_URL
|
||||
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun run --filter web build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]
|
||||
@@ -15,8 +15,6 @@
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@code/ui": "workspace:*",
|
||||
"@flue/react": "1.0.0-beta.9",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@react-router/fs-routes": "^8.1.0",
|
||||
"@react-router/node": "^8.1.0",
|
||||
"@react-router/serve": "^8.1.0",
|
||||
@@ -24,18 +22,18 @@
|
||||
"isbot": "^5.1.44",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0"
|
||||
"streamdown": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@react-router/dev": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"react-router-devtools": "^6.2.1",
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
||||
ssr: false,
|
||||
appDirectory: "src",
|
||||
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@code/ui/components/attachment";
|
||||
import type { FlueConversationPart } from "@flue/react";
|
||||
import { ImageIcon, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||
import type { ConversationPart } from "@/lib/chat/types";
|
||||
|
||||
type FilePart = Extract<FlueConversationPart, { type: "file" }>;
|
||||
type FilePart = Extract<ConversationPart, { type: "file" }>;
|
||||
|
||||
const isDirectlyRenderableUrl = (url: string): boolean =>
|
||||
url.startsWith("blob:") || url.startsWith("data:");
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
MobileChatBubble,
|
||||
MobileChatMessage,
|
||||
} from "@code/ui/components/mobile-chat";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
|
||||
import {
|
||||
getMessageText,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
isReasoningStreaming,
|
||||
isMessageStreaming,
|
||||
} from "@/lib/chat/transforms";
|
||||
@@ -23,7 +21,6 @@ import type {
|
||||
|
||||
import { AssistantIdentity } from "./assistant-identity";
|
||||
import { MessageAttachments } from "./chat-attachments";
|
||||
import { ChatToolCall } from "./chat-tool-call";
|
||||
|
||||
const ReasoningTrace = ({
|
||||
isStreaming,
|
||||
@@ -96,27 +93,7 @@ const assistantState = (
|
||||
return isStreaming ? "writing" : undefined;
|
||||
};
|
||||
|
||||
const ToolActivity = ({
|
||||
hidden,
|
||||
message,
|
||||
}: {
|
||||
readonly hidden: boolean;
|
||||
readonly message: FlueConversationMessage;
|
||||
}) => {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
return message.parts.map((part) =>
|
||||
part.type === "dynamic-tool" ? (
|
||||
<ChatToolCall key={part.toolCallId} part={part} />
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatMessage = ({
|
||||
hideToolActivity = false,
|
||||
message,
|
||||
}: ChatMessageProps) => {
|
||||
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||
const isUser = message.role === "user";
|
||||
const isStreaming = isMessageStreaming(message);
|
||||
const text = getMessageText(message);
|
||||
@@ -124,16 +101,6 @@ export const ChatMessage = ({
|
||||
const reasoningStreaming = isReasoningStreaming(message);
|
||||
const fileParts = message.parts.filter((part) => part.type === "file");
|
||||
|
||||
if (
|
||||
hideToolActivity &&
|
||||
!isUser &&
|
||||
hasToolActivity(message) &&
|
||||
!text &&
|
||||
!reasoning
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.parts.length === 0 && !isStreaming) {
|
||||
return null;
|
||||
}
|
||||
@@ -167,7 +134,6 @@ export const ChatMessage = ({
|
||||
text={reasoning}
|
||||
/>
|
||||
)}
|
||||
<ToolActivity hidden={hideToolActivity} message={message} />
|
||||
{content}
|
||||
</MobileChatBubble>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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";
|
||||
|
||||
export const ChatToolCall = ({ part }: ChatToolCallProps) => {
|
||||
let detail =
|
||||
typeof part.input === "string"
|
||||
? part.input
|
||||
: JSON.stringify(part.input, null, 2);
|
||||
let status = "running";
|
||||
let tone: "error" | "neutral" | "success" = "neutral";
|
||||
|
||||
if (part.state === "output-available") {
|
||||
detail =
|
||||
typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output, null, 2);
|
||||
status = "done";
|
||||
tone = "success";
|
||||
} else if (part.state === "output-error") {
|
||||
detail = part.errorText;
|
||||
status = "failed";
|
||||
tone = "error";
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tool className="mb-0 w-full border-0" defaultOpen>
|
||||
<ToolContent className="space-y-0 p-0">
|
||||
<MobileChatToolCall
|
||||
detail={detail}
|
||||
icon={
|
||||
isSearch ? (
|
||||
<Search className="size-3" strokeWidth={2.2} />
|
||||
) : (
|
||||
<SquareTerminal className="size-3" strokeWidth={2.2} />
|
||||
)
|
||||
}
|
||||
status={status}
|
||||
tone={tone}
|
||||
toolName={part.toolName}
|
||||
/>
|
||||
<div className="sr-only">
|
||||
<ToolInput input={part.input} />
|
||||
<ToolOutput errorText={errorText} output={output} />
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
MobileHomeScreen,
|
||||
MobileWorkChatScreen,
|
||||
MobileWorkListScreen,
|
||||
MobileWorkStackScreen,
|
||||
MobileWorkUnitDetailScreen,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
||||
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
|
||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
||||
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
|
||||
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
|
||||
import { getUserInitials } from "@/lib/users/get-user-initials";
|
||||
|
||||
export type MobileFlowScreen =
|
||||
| "assistant-chat"
|
||||
| "home"
|
||||
| "stack-home"
|
||||
| "work-chat"
|
||||
| "work-list"
|
||||
| "work-unit-detail";
|
||||
|
||||
interface MobileFlowPageProps {
|
||||
screen: MobileFlowScreen;
|
||||
}
|
||||
|
||||
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { workUnitId } = useParams();
|
||||
const auth = useWebAuth();
|
||||
const projectWorkspace = useMobileProjectWorkspace({
|
||||
selectedWorkUnitId: workUnitId,
|
||||
});
|
||||
const workspace = useMobileWorkspace({
|
||||
onCreateIssue: (title, body) =>
|
||||
projectWorkspace.raiseIssue({ body, title }),
|
||||
onSend: projectWorkspace.sendMessage,
|
||||
});
|
||||
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
|
||||
const workPath = "/work";
|
||||
const chatPath = "/chat";
|
||||
const handleRestoreChecked = workStack.restoreChecked;
|
||||
const handleWorkUnitChecked = workStack.markChecked;
|
||||
const handleWorkUnitSentBack = workStack.sendToBack;
|
||||
const user = auth.status === "authenticated" ? auth.user : undefined;
|
||||
const handleSignOut = async () => {
|
||||
await signOutWeb();
|
||||
navigate("/login");
|
||||
};
|
||||
const statusMessage = getMobileStatusMessage({
|
||||
assistantError: projectWorkspace.error?.message,
|
||||
localStatus: workspace.statusMessage,
|
||||
});
|
||||
const handleRepositoryChange =
|
||||
projectWorkspace.projectWorkspace.setRepository;
|
||||
|
||||
const handleOpenUnit = (selectedWorkUnitId?: string) => {
|
||||
const targetId =
|
||||
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
||||
if (!targetId) {
|
||||
workspace.setProjectManagerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (screen === "work-list" && !workspace.expanded) {
|
||||
workspace.setExpanded(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/chat/${targetId}`);
|
||||
};
|
||||
|
||||
const screenProps = {
|
||||
composerValue: workspace.composerValue,
|
||||
createIssueBody: workspace.issueBody,
|
||||
createIssueTitle: workspace.issueTitle,
|
||||
data: projectWorkspace.data,
|
||||
modelId: MODEL_ID,
|
||||
modelLabel: MODEL_LABEL,
|
||||
onBack: () => navigate(chatPath),
|
||||
onComposerChange: workspace.handleComposerChange,
|
||||
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
|
||||
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: () => workspace.setProjectManagerOpen(true),
|
||||
onOpenAssistant: () => navigate(chatPath),
|
||||
onOpenUnit: handleOpenUnit,
|
||||
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
|
||||
onProjectSelect: (projectId: string) =>
|
||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||
projectId as Id<"projects">
|
||||
),
|
||||
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
|
||||
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void projectWorkspace.projectWorkspace.connectRepository();
|
||||
},
|
||||
onReviewUnit: (reviewUrl: string) => {
|
||||
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
onSettingsClose: () => workspace.setSettingsOpen(false),
|
||||
onSettingsOpen: () => workspace.setSettingsOpen(true),
|
||||
onStartUnit: (selectedIssueId: string) => {
|
||||
void projectWorkspace.startWorkUnit(
|
||||
selectedIssueId as Id<"projectIssues">
|
||||
);
|
||||
},
|
||||
onViewWork: () => navigate(workPath),
|
||||
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
||||
projectManagerOpen: workspace.projectManagerOpen,
|
||||
repositoryValue: projectWorkspace.projectWorkspace.repository,
|
||||
settingsOpen: workspace.settingsOpen,
|
||||
statusMessage,
|
||||
};
|
||||
|
||||
if (screen === "assistant-chat") {
|
||||
return <MobileAssistantChatScreen {...screenProps} />;
|
||||
}
|
||||
if (screen === "stack-home") {
|
||||
return (
|
||||
<MobileWorkStackScreen
|
||||
checkedCount={workStack.checkedCount}
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
modelId={MODEL_ID}
|
||||
modelLabel={MODEL_LABEL}
|
||||
onAddProject={() => workspace.setProjectManagerOpen(true)}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
onOpenUnit={(selectedWorkUnitId) =>
|
||||
navigate(`/chat/${selectedWorkUnitId}`)
|
||||
}
|
||||
onProjectSelect={(projectId) =>
|
||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||
projectId as Id<"projects">
|
||||
)
|
||||
}
|
||||
onProjectManagerClose={() => workspace.setProjectManagerOpen(false)}
|
||||
onRepositoryChange={handleRepositoryChange}
|
||||
onRepositoryConnect={(event) => {
|
||||
event.preventDefault();
|
||||
void projectWorkspace.projectWorkspace.connectRepository();
|
||||
}}
|
||||
onRestoreChecked={handleRestoreChecked}
|
||||
onSettingsClose={() => workspace.setSettingsOpen(false)}
|
||||
onSettingsOpen={() => workspace.setSettingsOpen(true)}
|
||||
onSignOut={() => {
|
||||
void handleSignOut();
|
||||
}}
|
||||
onWorkUnitChecked={handleWorkUnitChecked}
|
||||
onWorkUnitSentBack={handleWorkUnitSentBack}
|
||||
pendingAction={projectWorkspace.projectWorkspace.pendingAction}
|
||||
projectManagerOpen={workspace.projectManagerOpen}
|
||||
repositoryValue={projectWorkspace.projectWorkspace.repository}
|
||||
settingsOpen={workspace.settingsOpen}
|
||||
statusMessage={statusMessage}
|
||||
userEmail={user?.email}
|
||||
userInitials={getUserInitials(user?.name)}
|
||||
userName={user?.name}
|
||||
workUnits={workStack.visibleWorkUnits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "work-chat") {
|
||||
return (
|
||||
<MobileWorkChatScreen
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
onBack={() => navigate(chatPath)}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
statusMessage={statusMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "home") {
|
||||
return <MobileHomeScreen {...screenProps} />;
|
||||
}
|
||||
if (screen === "work-unit-detail") {
|
||||
return <MobileWorkUnitDetailScreen {...screenProps} />;
|
||||
}
|
||||
if (workspace.expanded) {
|
||||
return <MobileExpandedWorkScreen {...screenProps} />;
|
||||
}
|
||||
return <MobileWorkListScreen {...screenProps} />;
|
||||
};
|
||||
@@ -1,416 +0,0 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
ChevronRight,
|
||||
FolderGit2,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
MessageSquareText,
|
||||
ImagePlus,
|
||||
Send,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
|
||||
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
|
||||
import { chatImageToPromptImage } from "@/lib/chat/attachments";
|
||||
import {
|
||||
buildSliceOneTimeline,
|
||||
findSourceMessageTarget,
|
||||
} from "@/lib/slice-one/presentation";
|
||||
|
||||
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
|
||||
const EMPTY_WORKS: readonly SliceWork[] = [];
|
||||
|
||||
interface WorkCardProps {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly work: SliceWork;
|
||||
}
|
||||
|
||||
const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||
return (
|
||||
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Proposed Work
|
||||
</p>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||
{work.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||
{work.objective}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||
onClick={() => setSourcesOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{sources.length} exact source{" "}
|
||||
{sources.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{sourcesOpen ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||
key={source.messageId}
|
||||
onClick={() => onSourceSelect(source.rawText)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
|
||||
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const SliceOnePage = () => {
|
||||
const slice = useSliceOne();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
const [draft, setDraft] = useState("");
|
||||
const attachments = useChatImages();
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const works = slice.works ?? EMPTY_WORKS;
|
||||
const workById = useMemo(
|
||||
() => new Map(works.map((work) => [String(work._id), work])),
|
||||
[works]
|
||||
);
|
||||
const notices = useMemo(() => projectWorkNotices(works), [works]);
|
||||
const timeline = useMemo(
|
||||
() => buildSliceOneTimeline(slice.agent.messages, notices),
|
||||
[notices, slice.agent.messages]
|
||||
);
|
||||
const busy =
|
||||
slice.agent.status === "submitted" || slice.agent.status === "streaming";
|
||||
|
||||
const revealSourceMessage = (rawText: string) => {
|
||||
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setHighlightedMessageId(messageId);
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
highlightTimer.current = setTimeout(
|
||||
() => setHighlightedMessageId(undefined),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
if (slice.projects === undefined) {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!slice.selectedProject) {
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||
<form
|
||||
className="w-full max-w-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void slice.connectRepository();
|
||||
}}
|
||||
>
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||
Slice 1 turns actionable conversation into proposed Work with exact
|
||||
provenance.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Public Git repository URL"
|
||||
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => slice.setRepository(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
required
|
||||
value={slice.repository}
|
||||
/>
|
||||
{slice.error ? (
|
||||
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 h-12 w-full"
|
||||
disabled={slice.pending}
|
||||
type="submit"
|
||||
>
|
||||
{slice.pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{slice.pending ? "Connecting" : "Connect project"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
const images = await Promise.all(
|
||||
attachments.images.map(chatImageToPromptImage)
|
||||
);
|
||||
await slice.agent.sendMessage(message, { images });
|
||||
setDraft("");
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||
style={viewportStyle}
|
||||
>
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<select
|
||||
aria-label="Current project"
|
||||
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||
onChange={(event) => slice.selectProject(event.target.value)}
|
||||
value={slice.selectedProject.id}
|
||||
>
|
||||
{slice.projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] uppercase text-[#858277]">
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{slice.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
</header>
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||
{slice.agent.historyReady && timeline.length === 0 ? (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">
|
||||
What should move forward?
|
||||
</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem. Casual conversation stays
|
||||
conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
const work = workById.get(item.notice.workId);
|
||||
return work ? (
|
||||
<div
|
||||
className="chat-message ml-7"
|
||||
key={`notice-${item.notice.eventId}`}
|
||||
>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Work proposed from this conversation
|
||||
</p>
|
||||
<WorkCard
|
||||
onSourceSelect={revealSourceMessage}
|
||||
work={work}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm transition-colors duration-300 ${
|
||||
highlightedMessageId === item.message.id
|
||||
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
|
||||
: ""
|
||||
}`}
|
||||
id={`slice-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage hideToolActivity message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slice.agent.status === "submitted" ? (
|
||||
<ChatThinkingResponse />
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{attachments.images.length > 0 ? (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<PendingChatAttachments
|
||||
images={attachments.images}
|
||||
onRemove={attachments.handleRemove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{slice.agent.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{slice.agent.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{attachments.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{attachments.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||
<input
|
||||
accept="image/*"
|
||||
aria-label="Attach images"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
attachments.addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Attach images"
|
||||
className="size-11 shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
aria-label="Message Zopu"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim() || busy}
|
||||
onClick={() => void send()}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||
<p className="mb-4 text-xs text-[#747168]">
|
||||
{works.length} durable outcomes
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{drawerOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="absolute inset-0"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="grid size-9 place-items-center"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{works.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-[#747168]">
|
||||
Actionable messages will appear here.
|
||||
</p>
|
||||
) : null}
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ConversationComposer = ({
|
||||
draft,
|
||||
onDraftChange,
|
||||
workspace,
|
||||
}: {
|
||||
readonly draft: string;
|
||||
readonly onDraftChange: (draft: string) => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const attachments = useChatImages();
|
||||
const busy =
|
||||
workspace.agent.status === "submitted" ||
|
||||
workspace.agent.status === "streaming";
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
await workspace.agent.sendMessage(message, {
|
||||
images: attachments.images.map((image) => image.file),
|
||||
});
|
||||
onDraftChange("");
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{attachments.images.length > 0 ? (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<PendingChatAttachments
|
||||
images={attachments.images}
|
||||
onRemove={attachments.handleRemove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{workspace.agent.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{workspace.agent.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{attachments.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{attachments.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||
<input
|
||||
accept="image/*"
|
||||
aria-label="Attach images"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
attachments.addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Attach images"
|
||||
className="size-11 shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
aria-label="Message Zopu"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
|
||||
disabled={busy}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim() || busy}
|
||||
onClick={() => void send()}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { WorkCard } from "./work-card";
|
||||
|
||||
const ConversationLoading = () => (
|
||||
<output
|
||||
aria-label="Loading conversation"
|
||||
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||
>
|
||||
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="sr-only">Loading conversation…</span>
|
||||
</output>
|
||||
);
|
||||
|
||||
const ConversationEmptyState = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem. Casual conversation stays conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationFeed = ({
|
||||
highlightedMessageId,
|
||||
onSourceSelect,
|
||||
workspace,
|
||||
}: {
|
||||
readonly highlightedMessageId?: string;
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const works = workspace.works ?? [];
|
||||
const workById = new Map(
|
||||
works.map((work) => [String(work._id), work] as const)
|
||||
);
|
||||
const notices = projectWorkNotices(works);
|
||||
const timeline = useMemo(
|
||||
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
|
||||
[notices, workspace.agent.messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||
{!workspace.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationLoading />
|
||||
) : null}
|
||||
{workspace.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationEmptyState />
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
const work = workById.get(item.notice.workId) as
|
||||
| WorkRecord
|
||||
| undefined;
|
||||
return work ? (
|
||||
<div
|
||||
className="chat-message ml-7"
|
||||
key={`notice-${item.notice.eventId}`}
|
||||
>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Work proposed from this conversation
|
||||
</p>
|
||||
<div className="rounded-sm">
|
||||
<span className="sr-only">Proposed Work</span>
|
||||
<WorkCard
|
||||
onSourceSelect={onSourceSelect}
|
||||
work={work}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm transition-colors duration-300 ${highlightedMessageId === item.message.id ? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]" : ""}`}
|
||||
id={`workspace-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{workspace.agent.status === "submitted" ? (
|
||||
<ChatThinkingResponse />
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { FolderGit2, LoaderCircle } from "lucide-react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ProjectConnectForm = ({
|
||||
workspace,
|
||||
}: {
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||
<form
|
||||
className="w-full max-w-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void workspace.connectRepository();
|
||||
}}
|
||||
>
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Connect a project</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||
Turn actionable project conversation into proposed Work with exact
|
||||
provenance.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Public Git repository URL"
|
||||
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => workspace.setRepository(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
required
|
||||
value={workspace.repository}
|
||||
/>
|
||||
{workspace.error ? (
|
||||
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 h-12 w-full"
|
||||
disabled={workspace.pending}
|
||||
type="submit"
|
||||
>
|
||||
{workspace.pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{workspace.pending ? "Connecting" : "Connect project"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
63
apps/web/src/components/workspace/project-header.tsx
Normal file
63
apps/web/src/components/workspace/project-header.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { ProjectSettingsPanel } from "./project-settings-panel";
|
||||
|
||||
export const ProjectHeader = ({
|
||||
onOpenDrawer,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onOpenDrawer: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const works = workspace.works ?? [];
|
||||
|
||||
return (
|
||||
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<select
|
||||
aria-label="Current project"
|
||||
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||
onChange={(event) => workspace.selectProject(event.target.value)}
|
||||
value={workspace.selectedProject?.id ?? ""}
|
||||
>
|
||||
{(workspace.projects ?? []).map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] uppercase text-[#858277]">
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Project settings"
|
||||
className="mr-2 size-9"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={onOpenDrawer}
|
||||
type="button"
|
||||
>
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{workspace.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
{settingsOpen ? (
|
||||
<ProjectSettingsPanel
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
workspace={workspace}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
100
apps/web/src/components/workspace/project-settings-panel.tsx
Normal file
100
apps/web/src/components/workspace/project-settings-panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ProjectSettingsPanel = ({
|
||||
onClose,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onClose: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
|
||||
const [username, setUsername] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const handleClearOperationError = () => workspace.clearOperationError();
|
||||
|
||||
return (
|
||||
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Project Git</h2>
|
||||
<button aria-label="Close settings" onClick={onClose} type="button">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#747168]">
|
||||
{workspace.projectGitConnection
|
||||
? `${workspace.projectGitConnection.provider} · ${workspace.projectGitConnection.serverUrl}`
|
||||
: "No Git credentials attached"}
|
||||
</p>
|
||||
{workspace.operationError ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 leading-5">
|
||||
{workspace.operationError.message}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Dismiss error"
|
||||
className="shrink-0"
|
||||
onClick={handleClearOperationError}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void workspace.authorizeGithub()}
|
||||
>
|
||||
Authorize GitHub
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void workspace.connectLinkedGithub()}>
|
||||
Use GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
||||
<input
|
||||
aria-label="Gitea server URL"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
value={serverUrl}
|
||||
/>
|
||||
<input
|
||||
aria-label="Gitea username"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
value={username}
|
||||
/>
|
||||
<input
|
||||
aria-label="Gitea access token"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Personal access token"
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!token.trim()}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void workspace.connectGitea({
|
||||
serverUrl,
|
||||
token,
|
||||
username: username || undefined,
|
||||
});
|
||||
setToken("");
|
||||
}}
|
||||
>
|
||||
Connect Gitea
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
|
||||
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
|
||||
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
|
||||
|
||||
import { ConversationComposer } from "./conversation-composer";
|
||||
import { ConversationFeed } from "./conversation-feed";
|
||||
import { ProjectConnectForm } from "./project-connect-form";
|
||||
import { ProjectHeader } from "./project-header";
|
||||
import { WorkFeed } from "./work-feed";
|
||||
|
||||
const ProjectLoading = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
|
||||
</main>
|
||||
);
|
||||
|
||||
export const ProjectWorkspacePage = () => {
|
||||
const workspace = useProjectWorkspace();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
if (workspace.projects === undefined) {
|
||||
return <ProjectLoading />;
|
||||
}
|
||||
if (!workspace.selectedProject) {
|
||||
return <ProjectConnectForm workspace={workspace} />;
|
||||
}
|
||||
|
||||
const works = workspace.works ?? [];
|
||||
const revealSourceMessage = (rawText: string) => {
|
||||
const messageId = findSourceMessageTarget(
|
||||
workspace.agent.messages,
|
||||
rawText
|
||||
);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setHighlightedMessageId(messageId);
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
);
|
||||
highlightTimer.current = setTimeout(
|
||||
() => setHighlightedMessageId(undefined),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="workspace-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||
style={viewportStyle}
|
||||
>
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<ProjectHeader
|
||||
onOpenDrawer={() => setDrawerOpen(true)}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<ConversationFeed
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<ConversationComposer
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</section>
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||
<p className="mb-4 text-xs text-[#747168]">
|
||||
{works.length} durable outcomes
|
||||
</p>
|
||||
<WorkFeed
|
||||
onSourceSelect={revealSourceMessage}
|
||||
works={works}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</aside>
|
||||
{drawerOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="absolute inset-0"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="grid size-9 place-items-center"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<WorkFeed
|
||||
onSourceSelect={revealSourceMessage}
|
||||
works={works}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
395
apps/web/src/components/workspace/work-card.tsx
Normal file
395
apps/web/src/components/workspace/work-card.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
Hammer,
|
||||
Play,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { changedFilesFor } from "@/lib/workspace/types";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
interface WorkCardProps {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly work: WorkRecord;
|
||||
readonly workspace: WorkspaceState;
|
||||
}
|
||||
|
||||
const starterDefinition = (work: WorkRecord) => ({
|
||||
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
||||
affectedUsers: ["Project users"],
|
||||
assumptions: [],
|
||||
constraints: [],
|
||||
desiredOutcome: work.objective,
|
||||
inScope: [work.objective],
|
||||
outOfScope: ["Unrelated product changes"],
|
||||
problem: work.objective,
|
||||
questions: [],
|
||||
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
||||
risk: "medium",
|
||||
});
|
||||
|
||||
const starterDesign = (work: WorkRecord) => ({
|
||||
architectureSummary:
|
||||
"Validate the approved Definition, then exercise one deterministic fake slice.",
|
||||
callFlowDelta: [
|
||||
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
|
||||
],
|
||||
concerns: [],
|
||||
evidenceRequirements: ["Terminal Run classification"],
|
||||
fileTreeDelta: [],
|
||||
impactMap: {
|
||||
files: [],
|
||||
modules: [],
|
||||
risks: [],
|
||||
summary: "Compact vertical-slice simulation",
|
||||
},
|
||||
invariants: [
|
||||
"Simulation never claims implementation",
|
||||
"Every Attempt reaches a terminal classification",
|
||||
],
|
||||
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
||||
slices: [
|
||||
{
|
||||
codeBoundaries: ["workExecution"],
|
||||
dependsOn: [],
|
||||
evidenceRequirements: ["Normalized activity events"],
|
||||
id: "deterministic-simulation",
|
||||
objective: work.objective,
|
||||
observableBehavior: "A terminal fake Run is visible",
|
||||
reviewRequired: false,
|
||||
title: "Deterministic simulation",
|
||||
verification: ["Run completes with a terminal classification"],
|
||||
},
|
||||
],
|
||||
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
|
||||
export const WorkCard = ({
|
||||
onSourceSelect,
|
||||
work,
|
||||
workspace,
|
||||
}: WorkCardProps) => {
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||
const [latestRun] = work.runs;
|
||||
const openQuestions =
|
||||
work.definition?.questions?.filter((question) => question.status === "open")
|
||||
.length ?? 0;
|
||||
|
||||
return (
|
||||
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Proposed Work
|
||||
</p>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||
{work.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||
{work.objective}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||
onClick={() => setSourcesOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{sources.length} exact source{" "}
|
||||
{sources.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{sourcesOpen ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||
key={source.messageId}
|
||||
onClick={() => onSourceSelect(source.rawText)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Outcome
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
||||
<p className="mt-2 text-[#747168]">
|
||||
Risk: {work.definition?.risk ?? "not defined"}
|
||||
</p>
|
||||
{openQuestions > 0 ? (
|
||||
<p className="mt-1 text-amber-800">
|
||||
{openQuestions} open question(s)
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "proposed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void workspace.requestDefinition(work._id)}
|
||||
>
|
||||
<Sparkles className="size-3.5" /> Define
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "defining" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.saveDefinition(
|
||||
work._id,
|
||||
starterDefinition(work)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save definition
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-definition-approval" &&
|
||||
work.definitionVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void workspace.approveDefinition(
|
||||
work._id,
|
||||
work.definitionVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Design
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
{work.design?.architectureSummary ?? "No Design Packet yet."}
|
||||
</p>
|
||||
{work.design?.slices?.map((item) => (
|
||||
<p className="mt-1 text-[#747168]" key={item.id}>
|
||||
{item.title}: {item.observableBehavior}
|
||||
</p>
|
||||
))}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "designing" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.saveDesign(work._id, starterDesign(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save design
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-design-approval" &&
|
||||
work.definitionApprovalVersion &&
|
||||
work.designVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void workspace.approveDesign(
|
||||
work._id,
|
||||
work.definitionApprovalVersion as number,
|
||||
work.designVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve design
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Build
|
||||
</p>
|
||||
{workspace.operationError ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 leading-5">
|
||||
{workspace.operationError.message}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Dismiss error"
|
||||
className="shrink-0"
|
||||
onClick={() => workspace.clearOperationError()}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{latestRun ? (
|
||||
<div className="mt-1 space-y-2 text-[#626057]">
|
||||
<p className="leading-5">
|
||||
{latestRun.executionKind === "real"
|
||||
? "AgentOS"
|
||||
: "Simulation"}{" "}
|
||||
run {latestRun.status}:{" "}
|
||||
{latestRun.terminalSummary ??
|
||||
latestRun.terminalClassification ??
|
||||
"activity is still arriving"}
|
||||
</p>
|
||||
{latestRun.baseRevision ? (
|
||||
<p className="font-mono text-[10px] text-[#747168]">
|
||||
{latestRun.baseRevision.slice(0, 8)} →{" "}
|
||||
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
|
||||
</p>
|
||||
) : null}
|
||||
{latestRun.artifacts?.map((artifact) => (
|
||||
<div key={artifact._id} className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{artifact.uri ? (
|
||||
<a
|
||||
className="underline"
|
||||
href={artifact.uri}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{artifact.title}
|
||||
</a>
|
||||
) : (
|
||||
artifact.title
|
||||
)}
|
||||
</p>
|
||||
{changedFilesFor(artifact).length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{changedFilesFor(artifact).map((file) => (
|
||||
<li
|
||||
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
|
||||
key={file}
|
||||
>
|
||||
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
|
||||
<span className="min-w-0 break-all">{file}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{latestRun.attemptEvents &&
|
||||
latestRun.attemptEvents.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{(logsOpen
|
||||
? latestRun.attemptEvents
|
||||
: latestRun.attemptEvents.slice(-3)
|
||||
).map((item) => (
|
||||
<p
|
||||
className="border-l-2 border-[#b8c760] pl-2 leading-5"
|
||||
key={item._id}
|
||||
>
|
||||
{item.message}
|
||||
</p>
|
||||
))}
|
||||
{latestRun.attemptEvents.length > 3 ? (
|
||||
<button
|
||||
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
|
||||
onClick={() => setLogsOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<ScrollText className="size-3.5" />
|
||||
{logsOpen
|
||||
? "Show recent activity"
|
||||
: `Show full activity log (${latestRun.attemptEvents.length})`}
|
||||
<ChevronRight
|
||||
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "ready" ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!workspace.projectGitConnection}
|
||||
onClick={() => void workspace.startExecution(work._id)}
|
||||
>
|
||||
<Play className="size-3.5" /> Run
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.startSimulation(work._id, "success")
|
||||
}
|
||||
>
|
||||
<Play className="size-3.5" /> Simulate
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{latestRun?.status === "running" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void (latestRun.executionKind === "real"
|
||||
? workspace.cancelExecution(latestRun._id)
|
||||
: workspace.cancelSimulation(latestRun._id))
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" /> Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.executionKind !== "real" &&
|
||||
latestRun?.status === "terminal" &&
|
||||
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void workspace.retrySimulation(latestRun._id)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { WorkCard } from "./work-card";
|
||||
|
||||
export const WorkFeed = ({
|
||||
onSourceSelect,
|
||||
works,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly works: readonly WorkRecord[];
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
{works.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-[#747168]">
|
||||
Actionable messages will appear here.
|
||||
</p>
|
||||
) : null}
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={onSourceSelect}
|
||||
work={work}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useChatAgent } from "./use-chat-agent";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: {
|
||||
error: undefined,
|
||||
historyReady: true,
|
||||
messages: [],
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
status: "idle" as const,
|
||||
},
|
||||
organization: {} as { error?: Error; organizationId?: string },
|
||||
useFlueAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@flue/react", () => ({
|
||||
useFlueAgent: mocks.useFlueAgent,
|
||||
}));
|
||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
||||
usePersonalOrganization: () => mocks.organization,
|
||||
}));
|
||||
|
||||
describe("useChatAgent", () => {
|
||||
beforeEach(() => {
|
||||
mocks.organization = {};
|
||||
mocks.agent.sendMessage.mockClear();
|
||||
mocks.useFlueAgent.mockReset();
|
||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
||||
});
|
||||
|
||||
test("does not use the agent before organization bootstrap completes", async () => {
|
||||
const chat = useChatAgent();
|
||||
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
expect(chat.status).toBe("connecting");
|
||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
||||
"Personal organization is still being prepared"
|
||||
);
|
||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses the ensured organization id for the agent session", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
|
||||
const chat = useChatAgent();
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: "org-a",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
|
||||
await chat.sendMessage("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready", undefined);
|
||||
});
|
||||
|
||||
test("forwards image attachments through to the Flue agent", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
const images = [
|
||||
{
|
||||
data: "abc",
|
||||
filename: "a.png",
|
||||
mimeType: "image/png",
|
||||
type: "image" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const chat = useChatAgent();
|
||||
await chat.sendMessage("describe this", { images });
|
||||
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
|
||||
images,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,111 @@
|
||||
import type { SendMessageOptions } from "@flue/react";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { projectConversation } from "@/lib/chat/conversation";
|
||||
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
|
||||
|
||||
const requestId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const uploadImage = async (
|
||||
file: File,
|
||||
generateUploadUrl: () => Promise<string>
|
||||
) => {
|
||||
const response = await fetch(await generateUploadUrl(), {
|
||||
body: file,
|
||||
headers: { "content-type": file.type },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image upload failed (${response.status})`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("storageId" in payload) ||
|
||||
typeof payload.storageId !== "string"
|
||||
) {
|
||||
throw new Error("Image upload returned an invalid response");
|
||||
}
|
||||
return {
|
||||
filename: file.name || undefined,
|
||||
mimeType: file.type,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
};
|
||||
};
|
||||
|
||||
export const useOrganizationChatAgent = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
const { organizationId } = organization;
|
||||
const rows = useQuery(
|
||||
api.conversationMessages.listForCurrentOrganization,
|
||||
organizationId ? { organizationId } : "skip"
|
||||
);
|
||||
const send = useMutation(api.conversationMessages.send);
|
||||
const generateUploadUrl = useMutation(
|
||||
api.conversationMessages.generateUploadUrl
|
||||
);
|
||||
const [sendError, setSendError] = useState<Error>();
|
||||
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
let status: AgentStatus = "idle";
|
||||
if (projected.streaming) {
|
||||
status = "streaming";
|
||||
} else if (projected.pending) {
|
||||
status = "submitted";
|
||||
}
|
||||
if (!organizationId || rows === undefined) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
} else if (projected.failedError || sendError) {
|
||||
status = "error";
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
options?: SendMessageOptions
|
||||
options?: { readonly images?: readonly File[] }
|
||||
): Promise<void> => {
|
||||
if (!organization.organizationId) {
|
||||
if (!organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message, options);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
(options?.images ?? []).map((file) =>
|
||||
uploadImage(file, generateUploadUrl)
|
||||
)
|
||||
);
|
||||
await send({
|
||||
clientRequestId: requestId(),
|
||||
images,
|
||||
organizationId,
|
||||
rawText: message,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
setSendError(normalized);
|
||||
throw normalized;
|
||||
}
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
if (!organization.organizationId) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
}
|
||||
|
||||
return {
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
error:
|
||||
organization.error ??
|
||||
sendError ??
|
||||
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||
historyReady: rows !== undefined,
|
||||
messages: projected.messages,
|
||||
sendMessage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||
import { generateBrowserRequestId } from "@/lib/flue-transport";
|
||||
|
||||
const generateImageId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
export const useChatImages = () => {
|
||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||
@@ -40,7 +43,7 @@ export const useChatImages = () => {
|
||||
previewUrls.current.add(previewUrl);
|
||||
next.push({
|
||||
file,
|
||||
id: generateBrowserRequestId(),
|
||||
id: generateImageId(),
|
||||
previewUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
export const useSliceOne = () => {
|
||||
const projects = useQuery(api.projects.list);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useChatAgent();
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
api.works.listForProject,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null,
|
||||
[activeProjectId, projects]
|
||||
);
|
||||
|
||||
const connectRepository = async () => {
|
||||
const repositoryUrl = repository.trim();
|
||||
if (!repositoryUrl || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl });
|
||||
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(toError(caughtError));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProject = (projectId: string) => {
|
||||
setSelectedProjectId(projectId as unknown as Id<"projects">);
|
||||
};
|
||||
|
||||
return {
|
||||
agent,
|
||||
connectRepository,
|
||||
error,
|
||||
pending,
|
||||
projects,
|
||||
repository,
|
||||
selectProject,
|
||||
selectedProject,
|
||||
setRepository,
|
||||
works,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
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 WORK_UNIT_SCOPE_PATTERN =
|
||||
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
|
||||
|
||||
const toAssistantMessages = (
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
|
||||
selectedWorkUnitId?: string
|
||||
): MobileAssistantMessageView[] => {
|
||||
const result: MobileAssistantMessageView[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" && message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const rawText = getMessageText(message);
|
||||
if (!rawText) {
|
||||
continue;
|
||||
}
|
||||
if (!selectedWorkUnitId) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
|
||||
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
|
||||
result.length = 0;
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: match.groups.message?.trim() ?? rawText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (result.length > 0) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
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 selectedIssue = projectWorkspace.issues?.find(
|
||||
(issue) => String(issue._id) === selectedWorkUnitId
|
||||
);
|
||||
const sendMessage = (message: string) => {
|
||||
if (!selectedIssue) {
|
||||
return chatAgent.sendMessage(message);
|
||||
}
|
||||
return chatAgent.sendMessage(
|
||||
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
|
||||
);
|
||||
};
|
||||
const assistant: MobileAssistantView = {
|
||||
isBusy:
|
||||
chatAgent.status === "connecting" ||
|
||||
chatAgent.status === "streaming" ||
|
||||
chatAgent.status === "submitted",
|
||||
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
|
||||
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 ??
|
||||
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
|
||||
organization,
|
||||
projectWorkspace,
|
||||
raiseIssue: projectWorkspace.raiseIssue,
|
||||
raiseIssueFromSignal: (signalId: string) =>
|
||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
||||
sendMessage,
|
||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const useMobileWorkStack = (
|
||||
workUnits: readonly MobileWorkUnitView[]
|
||||
) => {
|
||||
const [order, setOrder] = useState<readonly string[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const workUnitsById = useMemo(
|
||||
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
|
||||
[workUnits]
|
||||
);
|
||||
const availableIds = useMemo(
|
||||
() => new Set(workUnitsById.keys()),
|
||||
[workUnitsById]
|
||||
);
|
||||
const visibleWorkUnits = useMemo(() => {
|
||||
const retainedOrder = order.filter(
|
||||
(workUnitId) =>
|
||||
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
const retainedIds = new Set(retainedOrder);
|
||||
const addedIds = workUnits
|
||||
.map((workUnit) => workUnit.id)
|
||||
.filter(
|
||||
(workUnitId) =>
|
||||
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
|
||||
const workUnit = workUnitsById.get(workUnitId);
|
||||
return workUnit ? [workUnit] : [];
|
||||
});
|
||||
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
|
||||
|
||||
const sendToBack = useCallback(
|
||||
(workUnitId: string) => {
|
||||
if (visibleWorkUnits[0]?.id !== workUnitId) {
|
||||
return;
|
||||
}
|
||||
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
|
||||
},
|
||||
[visibleWorkUnits]
|
||||
);
|
||||
|
||||
const markChecked = useCallback((workUnitId: string) => {
|
||||
setCheckedIds((current) => new Set(current).add(workUnitId));
|
||||
}, []);
|
||||
|
||||
const restoreChecked = useCallback(() => {
|
||||
setCheckedIds(new Set());
|
||||
setOrder([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checkedCount: [...checkedIds].filter((workUnitId) =>
|
||||
availableIds.has(workUnitId)
|
||||
).length,
|
||||
markChecked,
|
||||
restoreChecked,
|
||||
sendToBack,
|
||||
visibleWorkUnits,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
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 [projectManagerOpen, setProjectManagerOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string>();
|
||||
|
||||
const handleComposerChange = (value: string) => {
|
||||
setComposerValue(value);
|
||||
setStatusMessage(undefined);
|
||||
};
|
||||
|
||||
const submitMessage = async (message: string) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Sending to Zopu…");
|
||||
try {
|
||||
await onSend(trimmedMessage);
|
||||
setComposerValue("");
|
||||
setStatusMessage("Sent to Zopu");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const sendCurrentMessage = async () => {
|
||||
try {
|
||||
await submitMessage(composerValue);
|
||||
} catch {
|
||||
// submitMessage already exposes the failure through statusMessage.
|
||||
}
|
||||
};
|
||||
void sendCurrentMessage();
|
||||
};
|
||||
|
||||
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,
|
||||
handleComposerMessageSubmit: submitMessage,
|
||||
handleComposerSubmit,
|
||||
handleCreateIssueSubmit,
|
||||
issueBody,
|
||||
issueTitle,
|
||||
projectManagerOpen,
|
||||
setExpanded,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setProjectManagerOpen,
|
||||
setSettingsOpen,
|
||||
settingsOpen,
|
||||
statusMessage,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useConvexAuth, useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const auth = useWebAuth();
|
||||
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||
const convexReady = isAuthenticated && !isRefreshing;
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
if (!convexReady || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!userId || state?.userId !== userId) {
|
||||
if (!convexReady || !userId || state?.userId !== userId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useFlueClient } from "@flue/react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
buildProjectLoopView,
|
||||
summarizeProjectIssues,
|
||||
} from "@/lib/projects/project-evidence";
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useProjectWorkspace = () => {
|
||||
const flueClient = useFlueClient();
|
||||
const projects = useQuery(api.projects.list);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [selectedIssueId, setSelectedIssueId] =
|
||||
useState<Id<"projectIssues"> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId =
|
||||
selectedProjectId ??
|
||||
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
||||
null;
|
||||
const artifacts = useQuery(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const issues = useQuery(
|
||||
api.projectIssues.list,
|
||||
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 () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssue = async (input?: {
|
||||
readonly body?: string;
|
||||
readonly title?: string;
|
||||
}) => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = input?.title ?? issueTitle;
|
||||
const nextBody = input?.body ?? issueBody;
|
||||
setPendingAction("issue");
|
||||
setError(null);
|
||||
try {
|
||||
const issueId = await createIssue({
|
||||
body: nextBody,
|
||||
projectId: activeProjectId,
|
||||
title: nextTitle,
|
||||
});
|
||||
setSelectedIssueId(issueId);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => {
|
||||
const actionKey = `issue:${issueId}`;
|
||||
setPendingAction(actionKey);
|
||||
setError(null);
|
||||
try {
|
||||
await beginIssue({ issueId });
|
||||
await flueClient.agents.send("project-manager", String(issueId), {
|
||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = errorMessage(caughtError);
|
||||
await markDispatchFailed({ error: message, issueId });
|
||||
setError(message);
|
||||
} finally {
|
||||
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 =
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? 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 {
|
||||
artifacts,
|
||||
connectRepository,
|
||||
error,
|
||||
events,
|
||||
issueBody,
|
||||
issueSummary,
|
||||
issueTitle,
|
||||
issues,
|
||||
pendingAction,
|
||||
projectLoop,
|
||||
projects,
|
||||
pullRequest,
|
||||
raiseIssue,
|
||||
raiseIssueFromSignal,
|
||||
repository,
|
||||
selectedIssue,
|
||||
selectedIssueId,
|
||||
selectedProject,
|
||||
selectedProjectId: activeProjectId,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setRepository,
|
||||
setSelectedIssueId,
|
||||
setSelectedProjectId,
|
||||
startIssue,
|
||||
startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
276
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
276
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
type ExecutionScenario =
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled";
|
||||
|
||||
const workListRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
WorkRecord[]
|
||||
>("workPlanning:listForProject");
|
||||
const requestDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works"> },
|
||||
unknown
|
||||
>("workPlanning:requestDefinition");
|
||||
const saveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDefinitionProposal");
|
||||
const approveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; version: number },
|
||||
unknown
|
||||
>("workPlanning:approveDefinition");
|
||||
const saveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDesignProposal");
|
||||
const approveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
|
||||
unknown
|
||||
>("workPlanning:approveDesign");
|
||||
const startSimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
|
||||
unknown
|
||||
>("workExecution:startSimulatedExecution");
|
||||
const cancelSimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecution:cancelSimulatedExecution");
|
||||
const retrySimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecution:retrySimulatedExecution");
|
||||
const startExecutionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; sliceId?: string },
|
||||
unknown
|
||||
>("workExecutionWorkflow:startExecution");
|
||||
const cancelExecutionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecutionWorkflow:cancelExecution");
|
||||
const listGitConnectionsRef = makeFunctionReference<
|
||||
"query",
|
||||
Record<string, never>,
|
||||
readonly {
|
||||
id: string;
|
||||
provider: "github" | "gitea";
|
||||
serverUrl: string;
|
||||
username?: string;
|
||||
}[]
|
||||
>("gitConnectionData:list");
|
||||
const projectGitConnectionRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
{
|
||||
id: string;
|
||||
provider: "github" | "gitea";
|
||||
serverUrl: string;
|
||||
username?: string;
|
||||
} | null
|
||||
>("gitConnectionData:getForProject");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ serverUrl: string; token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
"action",
|
||||
Record<string, never>,
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGithub");
|
||||
const attachGitConnectionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
|
||||
unknown
|
||||
>("gitConnectionData:attachToProject");
|
||||
|
||||
export const useProjectWorkspace = (): WorkspaceState => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [operationError, setOperationError] = useState<Error>();
|
||||
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const gitConnections = useQuery(
|
||||
listGitConnectionsRef,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const projectGitConnection = useQuery(
|
||||
projectGitConnectionRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
||||
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
||||
const approveDefinitionMutation = useMutation(approveDefinitionRef);
|
||||
const saveDesignMutation = useMutation(saveDesignRef);
|
||||
const approveDesignMutation = useMutation(approveDesignRef);
|
||||
const startSimulationMutation = useMutation(startSimulationRef);
|
||||
const cancelSimulationMutation = useMutation(cancelSimulationRef);
|
||||
const retrySimulationMutation = useMutation(retrySimulationRef);
|
||||
const startExecutionMutation = useMutation(startExecutionRef);
|
||||
const cancelExecutionMutation = useMutation(cancelExecutionRef);
|
||||
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
|
||||
const connectGiteaAction = useAction(connectGiteaRef);
|
||||
const connectGithubAction = useAction(connectGithubRef);
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null,
|
||||
[activeProjectId, projects]
|
||||
);
|
||||
|
||||
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
return await operation();
|
||||
} catch (caughtError) {
|
||||
setOperationError(toError(caughtError));
|
||||
throw caughtError;
|
||||
}
|
||||
};
|
||||
|
||||
const connectRepository = async () => {
|
||||
const repositoryUrl = repository.trim();
|
||||
if (!repositoryUrl || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl });
|
||||
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(toError(caughtError));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const attachConnection = async (connectionId: Id<"gitConnections">) => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error("Select a project first");
|
||||
}
|
||||
await attachGitConnectionMutation({
|
||||
connectionId,
|
||||
projectId: activeProjectId,
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitea = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
username?: string;
|
||||
}) =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGiteaAction(input);
|
||||
await attachConnection(result.connectionId);
|
||||
});
|
||||
|
||||
const connectLinkedGithub = () =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGithubAction({});
|
||||
await attachConnection(result.connectionId);
|
||||
});
|
||||
|
||||
return {
|
||||
agent,
|
||||
approveDefinition: (workId: Id<"works">, version: number) =>
|
||||
approveDefinitionMutation({ version, workId }),
|
||||
approveDesign: (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
||||
authorizeGithub: () =>
|
||||
authClient.signIn.social({
|
||||
callbackURL: window.location.href,
|
||||
provider: "github",
|
||||
}),
|
||||
cancelExecution: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => cancelExecutionMutation({ runId })),
|
||||
cancelSimulation: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => cancelSimulationMutation({ runId })),
|
||||
clearOperationError: () => setOperationError(undefined),
|
||||
connectGitea,
|
||||
connectLinkedGithub,
|
||||
connectRepository,
|
||||
error,
|
||||
gitConnections,
|
||||
operationError,
|
||||
pending,
|
||||
projectGitConnection,
|
||||
projects,
|
||||
repository,
|
||||
requestDefinition: (workId: Id<"works">) =>
|
||||
requestDefinitionMutation({ workId }),
|
||||
retrySimulation: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => retrySimulationMutation({ runId })),
|
||||
saveDefinition: (workId: Id<"works">, payload: unknown) =>
|
||||
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||
saveDesign: (workId: Id<"works">, payload: unknown) =>
|
||||
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||
selectProject: (projectId: string) =>
|
||||
setSelectedProjectId(projectId as unknown as Id<"projects">),
|
||||
selectedProject: selectedProject
|
||||
? { id: selectedProject.id, name: selectedProject.name }
|
||||
: null,
|
||||
setRepository,
|
||||
startExecution: (workId: Id<"works">, sliceId?: string) =>
|
||||
runOperation(() => startExecutionMutation({ sliceId, workId })),
|
||||
startSimulation: (
|
||||
workId: Id<"works">,
|
||||
scenario: ExecutionScenario,
|
||||
sliceId?: string
|
||||
) =>
|
||||
runOperation(() =>
|
||||
startSimulationMutation({ scenario, sliceId, workId })
|
||||
),
|
||||
works,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { visualViewportStyle } from "./use-visual-viewport";
|
||||
|
||||
describe("Slice 1 visual viewport", () => {
|
||||
describe("Workspace visual viewport", () => {
|
||||
test("shrinks the application surface to the keyboard-visible height", () => {
|
||||
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
|
||||
height: "500px",
|
||||
@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
||||
});
|
||||
};
|
||||
|
||||
root.classList.add("slice-one-viewport-lock");
|
||||
root.classList.add("workspace-viewport-lock");
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
viewport?.addEventListener("resize", update);
|
||||
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
root.classList.remove("slice-one-viewport-lock");
|
||||
root.classList.remove("workspace-viewport-lock");
|
||||
window.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("scroll", update);
|
||||
@@ -6,8 +6,8 @@ body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html.slice-one-viewport-lock,
|
||||
html.slice-one-viewport-lock body {
|
||||
html.workspace-viewport-lock,
|
||||
html.workspace-viewport-lock body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.route-progress-bar {
|
||||
animation: route-progress 900ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes route-progress {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes response-dot {
|
||||
0%,
|
||||
60%,
|
||||
@@ -103,9 +116,9 @@ html.slice-one-viewport-lock body {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.slice-one-surface .chat-markdown,
|
||||
.slice-one-surface .chat-reasoning,
|
||||
.slice-one-surface .thinking-line {
|
||||
.workspace-surface .chat-markdown,
|
||||
.workspace-surface .chat-reasoning,
|
||||
.workspace-surface .thinking-line {
|
||||
color: #232321;
|
||||
}
|
||||
|
||||
|
||||
64
apps/web/src/lib/auth.server.ts
Normal file
64
apps/web/src/lib/auth.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
interface AuthLoaderData {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
|
||||
|
||||
export const loadAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const cookie = request.headers.get("cookie");
|
||||
if (!cookie) {
|
||||
return { token: null };
|
||||
}
|
||||
|
||||
const headers = new Headers({ cookie });
|
||||
headers.set("host", tokenUrl.host);
|
||||
const response = await fetch(tokenUrl, { headers });
|
||||
if (response.status === 401) {
|
||||
return { token: null };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response("Authentication service unavailable", { status: 503 });
|
||||
}
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("token" in payload) ||
|
||||
typeof payload.token !== "string"
|
||||
) {
|
||||
throw new Response("Authentication service returned an invalid response", {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
return { token: payload.token };
|
||||
};
|
||||
|
||||
export const requireAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const auth = await loadAuthToken(request);
|
||||
if (!auth.token) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
|
||||
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
return auth;
|
||||
};
|
||||
|
||||
export const redirectAuthenticated = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const auth = await loadAuthToken(request);
|
||||
if (auth.token) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const returnTo = requestUrl.searchParams.get("returnTo");
|
||||
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
|
||||
}
|
||||
return auth;
|
||||
};
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_CHAT_IMAGE_BYTES,
|
||||
bytesToBase64,
|
||||
validateChatImage,
|
||||
} from "./attachments";
|
||||
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
|
||||
|
||||
describe("chat image attachments", () => {
|
||||
test("accepts supported image payloads within the upload limit", () => {
|
||||
@@ -24,8 +20,4 @@ describe("chat image attachments", () => {
|
||||
}).accepted
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("encodes image bytes for the Flue prompt contract", () => {
|
||||
expect(bytesToBase64(new Uint8Array([90, 111, 112, 117]))).toBe("Wm9wdQ==");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AgentPromptImage } from "@flue/react";
|
||||
|
||||
export const MAX_CHAT_IMAGES = 4;
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -28,20 +26,3 @@ export const validateChatImage = (
|
||||
}
|
||||
return { accepted: true };
|
||||
};
|
||||
|
||||
export const bytesToBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCodePoint(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
export const chatImageToPromptImage = async (
|
||||
image: PendingChatImage
|
||||
): Promise<AgentPromptImage> => ({
|
||||
data: bytesToBase64(new Uint8Array(await image.file.arrayBuffer())),
|
||||
filename: image.file.name,
|
||||
mimeType: image.file.type,
|
||||
type: "image",
|
||||
});
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { AgentStatus } from "@flue/react";
|
||||
import type { AgentStatus } from "./types";
|
||||
|
||||
/**
|
||||
* The global Zopu agent. The instance `id` is the current organization id
|
||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||
* instance. Organization scoping is the hard tenancy boundary.
|
||||
*/
|
||||
export const CHAT_AGENT = {
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
} as const;
|
||||
|
||||
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
|
||||
export const MODEL_LABEL = "GPT-OSS 20B Free";
|
||||
|
||||
export const SUGGESTIONS = [
|
||||
["Think it through", "Help me make a difficult decision"],
|
||||
["Make a plan", "Turn an idea into clear next steps"],
|
||||
["Explain simply", "Break down something complex"],
|
||||
] as const;
|
||||
|
||||
export const STATUS_COPY: Record<AgentStatus, string> = {
|
||||
connecting: "Connecting",
|
||||
error: "Connection issue",
|
||||
|
||||
59
apps/web/src/lib/chat/conversation.test.ts
Normal file
59
apps/web/src/lib/chat/conversation.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ConversationRow } from "./conversation";
|
||||
import { projectConversation } from "./conversation";
|
||||
|
||||
const row = (
|
||||
overrides: Partial<ConversationRow> &
|
||||
Pick<ConversationRow, "messageId" | "role" | "status">
|
||||
): ConversationRow => ({
|
||||
attachments: [],
|
||||
error: null,
|
||||
rawText: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("projectConversation", () => {
|
||||
test("keeps queued assistant rows as status instead of duplicate messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "user-1",
|
||||
rawText: "Build it",
|
||||
role: "user",
|
||||
status: "dispatching",
|
||||
}),
|
||||
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
|
||||
]);
|
||||
expect(state.pending).toBe(true);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("projects partial assistant text as streaming", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "assistant-streaming",
|
||||
rawText: "Working",
|
||||
role: "assistant",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
expect(state.streaming).toBe(true);
|
||||
expect(state.messages[0]?.parts).toEqual([
|
||||
{ state: "streaming", text: "Working", type: "text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("projects completed Convex rows into renderable messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "assistant-1",
|
||||
rawText: "Captured the request.",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
expect(state.messages[0]?.parts).toEqual([
|
||||
{ state: "done", text: "Captured the request.", type: "text" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
77
apps/web/src/lib/chat/conversation.ts
Normal file
77
apps/web/src/lib/chat/conversation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ConversationMessage } from "./types";
|
||||
|
||||
export interface ConversationRow {
|
||||
readonly attachments: readonly {
|
||||
readonly filename: string | null;
|
||||
readonly id: string;
|
||||
readonly mediaType: string;
|
||||
readonly url: string | null;
|
||||
}[];
|
||||
readonly error: string | null;
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly role: "assistant" | "user";
|
||||
readonly status:
|
||||
| "aborted"
|
||||
| "completed"
|
||||
| "dispatching"
|
||||
| "failed"
|
||||
| "queued"
|
||||
| "running";
|
||||
}
|
||||
|
||||
export const projectConversation = (rows: readonly ConversationRow[]) => {
|
||||
const streaming = rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
row.status === "running" &&
|
||||
row.rawText.length > 0
|
||||
);
|
||||
return {
|
||||
failedError: rows.findLast(
|
||||
(row) => row.role === "assistant" && row.status === "failed"
|
||||
)?.error,
|
||||
messages: rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.role === "user" ||
|
||||
row.status === "completed" ||
|
||||
(row.role === "assistant" &&
|
||||
row.status === "running" &&
|
||||
row.rawText.length > 0)
|
||||
)
|
||||
.map<ConversationMessage>((row) => ({
|
||||
id: row.messageId,
|
||||
parts: [
|
||||
...row.attachments.map((attachment) => ({
|
||||
filename: attachment.filename ?? undefined,
|
||||
id: attachment.id,
|
||||
mediaType: attachment.mediaType,
|
||||
type: "file" as const,
|
||||
url: attachment.url ?? undefined,
|
||||
})),
|
||||
...(row.rawText
|
||||
? [
|
||||
{
|
||||
state:
|
||||
row.status === "running"
|
||||
? ("streaming" as const)
|
||||
: ("done" as const),
|
||||
text: row.rawText,
|
||||
type: "text" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
role: row.role,
|
||||
})),
|
||||
pending: rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
(row.status === "queued" ||
|
||||
row.status === "dispatching" ||
|
||||
row.status === "running")
|
||||
),
|
||||
streaming,
|
||||
};
|
||||
};
|
||||
@@ -1,53 +1,18 @@
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
extractThinkingMarkup,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
isReasoningStreaming,
|
||||
} from "./transforms";
|
||||
import type { ConversationMessage } from "./types";
|
||||
|
||||
const message = (
|
||||
parts: FlueConversationMessage["parts"]
|
||||
): FlueConversationMessage => ({
|
||||
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
|
||||
id: "message-1",
|
||||
parts,
|
||||
role: "assistant",
|
||||
});
|
||||
|
||||
describe("hasToolActivity", () => {
|
||||
test("detects an orchestration turn", () => {
|
||||
expect(
|
||||
hasToolActivity(
|
||||
message([
|
||||
{
|
||||
input: { projectId: "project-1" },
|
||||
state: "input-available",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "list_proposed_work",
|
||||
type: "dynamic-tool",
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps a product-facing assistant response", () => {
|
||||
expect(
|
||||
hasToolActivity(
|
||||
message([
|
||||
{
|
||||
state: "done",
|
||||
text: "Captured the signal and proposed Work.",
|
||||
type: "text",
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning traces", () => {
|
||||
test("extracts completed and streaming MiniMax think blocks", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
|
||||
import type { AgentStatus, ConversationMessage } from "./types";
|
||||
|
||||
export const stripThinkingMarkup = (text: string): string => {
|
||||
const withoutCompletedBlocks = text.replaceAll(
|
||||
@@ -33,16 +33,16 @@ export const extractThinkingMarkup = (text: string): string => {
|
||||
return completed.join("\n\n");
|
||||
};
|
||||
|
||||
export const getRawMessageText = (message: FlueConversationMessage): string =>
|
||||
export const getRawMessageText = (message: ConversationMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
|
||||
export const getMessageText = (message: FlueConversationMessage): string =>
|
||||
export const getMessageText = (message: ConversationMessage): string =>
|
||||
stripThinkingMarkup(getRawMessageText(message));
|
||||
|
||||
export const getReasoningText = (message: FlueConversationMessage): string => {
|
||||
export const getReasoningText = (message: ConversationMessage): string => {
|
||||
const nativeReasoning = message.parts
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.map((part) => part.text)
|
||||
@@ -51,9 +51,7 @@ export const getReasoningText = (message: FlueConversationMessage): string => {
|
||||
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
|
||||
};
|
||||
|
||||
export const isReasoningStreaming = (
|
||||
message: FlueConversationMessage
|
||||
): boolean => {
|
||||
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
|
||||
const nativeReasoningStreaming = message.parts.some(
|
||||
(part) => part.type === "reasoning" && part.state === "streaming"
|
||||
);
|
||||
@@ -66,19 +64,12 @@ export const isReasoningStreaming = (
|
||||
return nativeReasoningStreaming || inlineReasoningStreaming;
|
||||
};
|
||||
|
||||
export const hasToolActivity = (message: FlueConversationMessage): boolean =>
|
||||
message.parts.some((part) => part.type === "dynamic-tool");
|
||||
|
||||
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
|
||||
message.parts.some((part) => {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return part.state === "input-available";
|
||||
}
|
||||
return (
|
||||
export const isMessageStreaming = (message: ConversationMessage): boolean =>
|
||||
message.parts.some(
|
||||
(part) =>
|
||||
(part.type === "text" || part.type === "reasoning") &&
|
||||
part.state === "streaming"
|
||||
);
|
||||
});
|
||||
|
||||
export const getStatusDotClass = (status: AgentStatus): string => {
|
||||
if (status === "error") {
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import type {
|
||||
AgentPromptImage,
|
||||
AgentStatus,
|
||||
FlueConversationMessage,
|
||||
FlueConversationPart,
|
||||
} from "@flue/react";
|
||||
export type AgentStatus =
|
||||
| "connecting"
|
||||
| "error"
|
||||
| "idle"
|
||||
| "streaming"
|
||||
| "submitted";
|
||||
|
||||
export type ConversationPart =
|
||||
| {
|
||||
readonly state: "done" | "streaming";
|
||||
readonly text: string;
|
||||
readonly type: "text";
|
||||
}
|
||||
| {
|
||||
readonly state: "done" | "streaming";
|
||||
readonly text: string;
|
||||
readonly type: "reasoning";
|
||||
}
|
||||
| {
|
||||
readonly filename?: string;
|
||||
readonly id?: string;
|
||||
readonly mediaType: string;
|
||||
readonly type: "file";
|
||||
readonly url?: string;
|
||||
};
|
||||
|
||||
export interface ConversationMessage {
|
||||
readonly id: string;
|
||||
readonly parts: ConversationPart[];
|
||||
readonly role: "assistant" | "user";
|
||||
}
|
||||
|
||||
export interface ChatAgentState {
|
||||
error?: Error;
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
messages: ConversationMessage[];
|
||||
sendMessage: (
|
||||
message: string,
|
||||
options?: { readonly images?: AgentPromptImage[] }
|
||||
options?: { readonly images?: readonly File[] }
|
||||
) => Promise<void>;
|
||||
status: AgentStatus;
|
||||
}
|
||||
@@ -24,7 +49,7 @@ export interface ChatComposerProps {
|
||||
|
||||
export interface ChatConversationProps {
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
messages: ConversationMessage[];
|
||||
onSuggestion: (suggestion: string) => void;
|
||||
status: AgentStatus;
|
||||
}
|
||||
@@ -34,12 +59,7 @@ export interface ChatHeaderProps {
|
||||
}
|
||||
|
||||
export interface ChatMessageProps {
|
||||
hideToolActivity?: boolean;
|
||||
message: FlueConversationMessage;
|
||||
}
|
||||
|
||||
export interface ChatToolCallProps {
|
||||
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
|
||||
export type AssistantResponseState = "thinking" | "writing";
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createFlueFetch, generateBrowserRequestId } from "./flue-transport";
|
||||
|
||||
const BASE_URL = new URL("https://flue.example/api/");
|
||||
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
|
||||
if (this !== globalThis) {
|
||||
throw new TypeError("fetch receiver must be globalThis");
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
} as typeof fetch;
|
||||
|
||||
describe("createFlueFetch", () => {
|
||||
test("overlapping SDK agent sends keep distinct request IDs", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>();
|
||||
const secondResponse = Promise.withResolvers<Response>();
|
||||
const bothStarted = Promise.withResolvers<boolean>();
|
||||
const capturedHeaders: Headers[] = [];
|
||||
let requestCount = 0;
|
||||
let requestId = 0;
|
||||
|
||||
const fetchImpl: typeof fetch = (input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
requestCount += 1;
|
||||
if (requestCount === 2) {
|
||||
bothStarted.resolve(true);
|
||||
}
|
||||
if (requestCount === 1) {
|
||||
return firstResponse.promise;
|
||||
}
|
||||
if (requestCount === 2) {
|
||||
return secondResponse.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${String(input)}`);
|
||||
};
|
||||
const client = createFlueClient({
|
||||
baseUrl: BASE_URL.toString(),
|
||||
fetch: createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => `request-${(requestId += 1)}`,
|
||||
}),
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
});
|
||||
|
||||
const firstSend = client.agents.send("zopu", "org-a", {
|
||||
message: "first",
|
||||
});
|
||||
const secondSend = client.agents.send("zopu", "org-a", {
|
||||
message: "second",
|
||||
});
|
||||
await bothStarted.promise;
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
|
||||
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
|
||||
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
|
||||
firstResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "http://internal/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
secondResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "http://internal/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "https://flue.example/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "https://flue.example/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("invokes fetch with the browser global receiver", async () => {
|
||||
await expect(
|
||||
createFlueFetch({ baseUrl: BASE_URL, fetchImpl: GLOBAL_RECEIVER_FETCH })(
|
||||
"https://flue.example/api/agents/zopu/org-a?view=history"
|
||||
)
|
||||
).resolves.toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
test("uses the default browser request ID generator without losing its receiver", async () => {
|
||||
const capturedHeaders: Headers[] = [];
|
||||
const fetchImpl: typeof fetch = (_input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
|
||||
await createFlueFetch({ baseUrl: BASE_URL, fetchImpl })(
|
||||
"https://flue.example/api/agents/zopu/org-a",
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toMatch(
|
||||
/^[0-9a-f-]{36}$/u
|
||||
);
|
||||
});
|
||||
|
||||
test("generates a UUID without requiring crypto.randomUUID", () => {
|
||||
expect(generateBrowserRequestId()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
||||
);
|
||||
});
|
||||
|
||||
test("history and stream observation requests remain untagged", async () => {
|
||||
const capturedHeaders: Headers[] = [];
|
||||
const fetchImpl: typeof fetch = (_input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
const flueFetch = createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => "must-not-be-used",
|
||||
});
|
||||
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
|
||||
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
interface FlueFetchOptions {
|
||||
readonly baseUrl: URL;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly generateRequestId?: () => string;
|
||||
}
|
||||
|
||||
export const generateBrowserRequestId = (): string => {
|
||||
const bytes = new Uint8Array(16);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = ((bytes[6] ?? 0) % 16) + 64;
|
||||
bytes[8] = ((bytes[8] ?? 0) % 64) + 128;
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
};
|
||||
|
||||
const addRequestContext = async (
|
||||
response: Response,
|
||||
method: string,
|
||||
requestUrl: URL
|
||||
): Promise<Response> => {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
const responseClone = response.clone();
|
||||
const responseText = await responseClone.text();
|
||||
const detail = responseText.trim() || "request failed";
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the Flue transport used by the browser client.
|
||||
*
|
||||
* The installed Flue SDK does not expose per-call headers on `agents.send`.
|
||||
* Its custom `fetch` seam does receive the resolved URL and method, so request
|
||||
* IDs are attached here to each agent admission POST. History and Durable
|
||||
* Streams observation requests are GETs and intentionally remain untagged.
|
||||
*/
|
||||
export const createFlueFetch = ({
|
||||
baseUrl,
|
||||
fetchImpl = fetch,
|
||||
generateRequestId = generateBrowserRequestId,
|
||||
}: FlueFetchOptions): typeof fetch => {
|
||||
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
||||
|
||||
return async (input, init) => {
|
||||
const inputUrl =
|
||||
typeof input === "string" || input instanceof URL ? input : input.url;
|
||||
const requestUrl = new URL(inputUrl, baseUrl);
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
|
||||
? requestUrl.pathname.slice(basePath.length)
|
||||
: requestUrl.pathname;
|
||||
const pathSegments = relativePath.split("/").filter(Boolean);
|
||||
const isAgentAdmission =
|
||||
method === "POST" &&
|
||||
pathSegments.length === 3 &&
|
||||
pathSegments[0] === "agents";
|
||||
|
||||
let requestInit = init;
|
||||
if (isAgentAdmission) {
|
||||
const headers = new Headers(
|
||||
input instanceof Request ? input.headers : undefined
|
||||
);
|
||||
for (const [key, value] of new Headers(init?.headers).entries()) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("x-zopu-request-id", generateRequestId());
|
||||
requestInit = { ...init, headers };
|
||||
}
|
||||
|
||||
const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
|
||||
const response = await addRequestContext(rawResponse, method, requestUrl);
|
||||
if (!response.headers.get("content-type")?.includes("application/json")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("streamUrl" in body) ||
|
||||
typeof body.streamUrl !== "string"
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const streamUrl = new URL(body.streamUrl);
|
||||
streamUrl.protocol = baseUrl.protocol;
|
||||
streamUrl.host = baseUrl.host;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("location", streamUrl.toString());
|
||||
return Response.json(
|
||||
{ ...body, streamUrl: streamUrl.toString() },
|
||||
{
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
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) => {
|
||||
const [source] = project.sources;
|
||||
return {
|
||||
connected: Boolean(source),
|
||||
host: source?.host,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
repositoryPath: source?.repositoryPath,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getMobileStatusMessage } from "./mobile-status-message";
|
||||
|
||||
describe("getMobileStatusMessage", () => {
|
||||
it("keeps local composer feedback", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError: "Flue API error 401",
|
||||
localStatus: "Sent to Zopu",
|
||||
})
|
||||
).toBe("Sent to Zopu");
|
||||
});
|
||||
|
||||
it("does not expose Flue authorization internals", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError:
|
||||
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
|
||||
})
|
||||
).toBe("Zopu is reconnecting…");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
interface MobileStatusMessageInput {
|
||||
readonly assistantError?: string;
|
||||
readonly localStatus?: string;
|
||||
}
|
||||
|
||||
const isAgentAuthorizationError = (message: string) =>
|
||||
message.includes("Flue API error 401") ||
|
||||
message.includes('"Unauthorized"') ||
|
||||
message.includes("GET /agents/");
|
||||
|
||||
export const getMobileStatusMessage = ({
|
||||
assistantError,
|
||||
localStatus,
|
||||
}: MobileStatusMessageInput): string | undefined => {
|
||||
if (localStatus) {
|
||||
return localStatus;
|
||||
}
|
||||
if (!assistantError) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAgentAuthorizationError(assistantError)) {
|
||||
return "Zopu is reconnecting…";
|
||||
}
|
||||
return "Zopu needs attention";
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
|
||||
const source = (relativePath: string) =>
|
||||
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
||||
|
||||
describe("Slice 1 frontend regression contracts", () => {
|
||||
describe("Workspace frontend regression contracts", () => {
|
||||
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
|
||||
const page = source("../../components/slice-one/slice-one-page.tsx");
|
||||
const page = source(
|
||||
"../../components/workspace/project-workspace-page.tsx"
|
||||
);
|
||||
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
|
||||
const root = source("../../root.tsx");
|
||||
const styles = source("../../index.css");
|
||||
|
||||
expect(root).toContain("interactive-widget=resizes-content");
|
||||
expect(page).toContain("style={viewportStyle}");
|
||||
expect(page).toContain("fixed inset-x-0 top-0");
|
||||
expect(page).not.toContain("slice-one-surface flex h-svh");
|
||||
expect(styles).toContain("html.slice-one-viewport-lock body");
|
||||
expect(viewport).toContain("workspace-viewport-lock");
|
||||
expect(styles).toContain("html.workspace-viewport-lock body");
|
||||
expect(styles).toContain("overflow: hidden");
|
||||
});
|
||||
|
||||
test("keeps the responsive shell shrinkable with a pinned composer", () => {
|
||||
const page = source("../../components/slice-one/slice-one-page.tsx");
|
||||
const page = source(
|
||||
"../../components/workspace/project-workspace-page.tsx"
|
||||
);
|
||||
const feed = source("../../components/workspace/conversation-feed.tsx");
|
||||
const composer = source(
|
||||
"../../components/workspace/conversation-composer.tsx"
|
||||
);
|
||||
|
||||
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
|
||||
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
|
||||
expect(page).toContain('className="shrink-0 border-t');
|
||||
expect(page).toContain(
|
||||
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
|
||||
expect(composer).toContain('className="shrink-0 border-t');
|
||||
expect(composer).toContain(
|
||||
'className="mx-auto flex max-w-2xl items-end gap-2"'
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
import {
|
||||
buildWorkspaceTimeline,
|
||||
findSourceMessageTarget,
|
||||
} from "./presentation";
|
||||
|
||||
const textMessage = (
|
||||
id: string,
|
||||
role: "assistant" | "user",
|
||||
text: string
|
||||
): FlueConversationMessage => ({
|
||||
): ConversationMessage => ({
|
||||
id,
|
||||
parts: [{ state: "done", text, type: "text" }],
|
||||
role,
|
||||
@@ -22,24 +26,11 @@ const notice: WorkNotice = {
|
||||
workId: "work-1",
|
||||
};
|
||||
|
||||
describe("Slice 1 presentation", () => {
|
||||
describe("Workspace presentation", () => {
|
||||
test("places proposed Work after the assistant response to its source", () => {
|
||||
const timeline = buildSliceOneTimeline(
|
||||
const timeline = buildWorkspaceTimeline(
|
||||
[
|
||||
textMessage("user-1", "user", "Build the phone flow."),
|
||||
{
|
||||
id: "tool-1",
|
||||
parts: [
|
||||
{
|
||||
input: {},
|
||||
state: "input-available",
|
||||
toolCallId: "call-1",
|
||||
toolName: "create_signal",
|
||||
type: "dynamic-tool",
|
||||
},
|
||||
],
|
||||
role: "assistant",
|
||||
},
|
||||
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
|
||||
],
|
||||
[notice]
|
||||
@@ -1,30 +1,23 @@
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
|
||||
import {
|
||||
getMessageText,
|
||||
getRawMessageText,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
} from "@/lib/chat/transforms";
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
export type SliceTimelineItem =
|
||||
| {
|
||||
readonly kind: "message";
|
||||
readonly message: FlueConversationMessage;
|
||||
}
|
||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||
import type { WorkspaceTimelineItem } from "./types";
|
||||
|
||||
export const isSliceOneVisibleMessage = (
|
||||
message: FlueConversationMessage
|
||||
export const isVisibleConversationMessage = (
|
||||
message: ConversationMessage
|
||||
): boolean =>
|
||||
message.role === "user" ||
|
||||
!hasToolActivity(message) ||
|
||||
getMessageText(message).length > 0 ||
|
||||
getReasoningText(message).length > 0;
|
||||
|
||||
const targetIndexForNotice = (
|
||||
messages: readonly FlueConversationMessage[],
|
||||
messages: readonly ConversationMessage[],
|
||||
notice: WorkNotice
|
||||
): number => {
|
||||
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
|
||||
@@ -40,17 +33,15 @@ const targetIndexForNotice = (
|
||||
const sourceIndex = Math.max(...sourceIndexes);
|
||||
const responseOffset = messages
|
||||
.slice(sourceIndex + 1)
|
||||
.findIndex(
|
||||
(message) => message.role === "assistant" && !hasToolActivity(message)
|
||||
);
|
||||
.findIndex((message) => message.role === "assistant");
|
||||
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
||||
};
|
||||
|
||||
export const buildSliceOneTimeline = (
|
||||
allMessages: readonly FlueConversationMessage[],
|
||||
export const buildWorkspaceTimeline = (
|
||||
allMessages: readonly ConversationMessage[],
|
||||
notices: readonly WorkNotice[]
|
||||
): readonly SliceTimelineItem[] => {
|
||||
const messages = allMessages.filter(isSliceOneVisibleMessage);
|
||||
): readonly WorkspaceTimelineItem[] => {
|
||||
const messages = allMessages.filter(isVisibleConversationMessage);
|
||||
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
|
||||
for (const notice of notices) {
|
||||
const targetIndex = targetIndexForNotice(messages, notice);
|
||||
@@ -59,7 +50,7 @@ export const buildSliceOneTimeline = (
|
||||
noticesByMessageIndex.set(targetIndex, atTarget);
|
||||
}
|
||||
|
||||
const timeline: SliceTimelineItem[] = [];
|
||||
const timeline: WorkspaceTimelineItem[] = [];
|
||||
for (const [index, message] of messages.entries()) {
|
||||
timeline.push({ kind: "message", message });
|
||||
for (const notice of noticesByMessageIndex.get(index) ?? []) {
|
||||
@@ -75,7 +66,7 @@ export const buildSliceOneTimeline = (
|
||||
};
|
||||
|
||||
export const findSourceMessageTarget = (
|
||||
messages: readonly FlueConversationMessage[],
|
||||
messages: readonly ConversationMessage[],
|
||||
sourceText: string
|
||||
): string | undefined =>
|
||||
messages.find(
|
||||
163
apps/web/src/lib/workspace/types.ts
Normal file
163
apps/web/src/lib/workspace/types.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
export interface WorkRecord {
|
||||
readonly _id: Id<"works">;
|
||||
readonly title: string;
|
||||
readonly objective: string;
|
||||
readonly status: string;
|
||||
readonly definitionVersion?: number;
|
||||
readonly definitionApprovalVersion?: number;
|
||||
readonly designVersion?: number;
|
||||
readonly signals: readonly {
|
||||
readonly sources: readonly { messageId: string; rawText: string }[];
|
||||
}[];
|
||||
readonly runs: readonly WorkRun[];
|
||||
readonly definition: {
|
||||
readonly risk?: string;
|
||||
readonly questions?: readonly { status: string }[];
|
||||
} | null;
|
||||
readonly design: {
|
||||
readonly architectureSummary?: string;
|
||||
readonly slices?: readonly {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly observableBehavior: string;
|
||||
}[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface WorkRun {
|
||||
readonly artifacts?: readonly WorkArtifact[];
|
||||
readonly attemptEvents?: readonly WorkAttemptEvent[];
|
||||
readonly baseRevision?: string;
|
||||
readonly candidateRevision?: string;
|
||||
readonly executionKind?: string;
|
||||
readonly _id: Id<"workRuns">;
|
||||
readonly status: string;
|
||||
readonly terminalClassification?: string;
|
||||
readonly terminalSummary?: string;
|
||||
}
|
||||
|
||||
export interface WorkArtifact {
|
||||
readonly _id: string;
|
||||
readonly metadataJson: string;
|
||||
readonly title: string;
|
||||
readonly uri?: string;
|
||||
}
|
||||
|
||||
export interface WorkAttemptEvent {
|
||||
readonly _id: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface GitConnection {
|
||||
readonly id: string;
|
||||
readonly provider: "github" | "gitea";
|
||||
readonly serverUrl: string;
|
||||
readonly username?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListItem {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
readonly agent: {
|
||||
readonly error?: Error;
|
||||
readonly historyReady: boolean;
|
||||
readonly messages: readonly ConversationMessage[];
|
||||
readonly sendMessage: (
|
||||
message: string,
|
||||
options?: { readonly images?: readonly File[] }
|
||||
) => Promise<void>;
|
||||
readonly status:
|
||||
| "connecting"
|
||||
| "error"
|
||||
| "idle"
|
||||
| "streaming"
|
||||
| "submitted";
|
||||
};
|
||||
readonly approveDefinition: (
|
||||
workId: Id<"works">,
|
||||
version: number
|
||||
) => Promise<unknown>;
|
||||
readonly approveDesign: (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => Promise<unknown>;
|
||||
readonly authorizeGithub: () => Promise<unknown>;
|
||||
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly clearOperationError: () => void;
|
||||
readonly connectGitea: (input: {
|
||||
readonly serverUrl: string;
|
||||
readonly token: string;
|
||||
readonly username?: string;
|
||||
}) => Promise<void>;
|
||||
readonly connectLinkedGithub: () => Promise<void>;
|
||||
readonly connectRepository: () => Promise<void>;
|
||||
readonly error?: Error;
|
||||
readonly gitConnections: readonly GitConnection[] | undefined;
|
||||
readonly operationError?: Error;
|
||||
readonly pending: boolean;
|
||||
readonly projectGitConnection: GitConnection | null | undefined;
|
||||
readonly projects: readonly ProjectListItem[] | undefined;
|
||||
readonly repository: string;
|
||||
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
|
||||
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly saveDefinition: (
|
||||
workId: Id<"works">,
|
||||
payload: unknown
|
||||
) => Promise<unknown>;
|
||||
readonly saveDesign: (
|
||||
workId: Id<"works">,
|
||||
payload: unknown
|
||||
) => Promise<unknown>;
|
||||
readonly selectProject: (projectId: string) => void;
|
||||
readonly selectedProject: ProjectListItem | null;
|
||||
readonly setRepository: (value: string) => void;
|
||||
readonly startExecution: (
|
||||
workId: Id<"works">,
|
||||
sliceId?: string
|
||||
) => Promise<unknown>;
|
||||
readonly startSimulation: (
|
||||
workId: Id<"works">,
|
||||
scenario:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled",
|
||||
sliceId?: string
|
||||
) => Promise<unknown>;
|
||||
readonly works: readonly WorkRecord[] | undefined;
|
||||
}
|
||||
|
||||
export type WorkspaceTimelineItem =
|
||||
| { readonly kind: "message"; readonly message: ConversationMessage }
|
||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||
|
||||
export interface ArtifactMetadata {
|
||||
readonly changedFiles?: readonly string[];
|
||||
}
|
||||
|
||||
export const parseArtifactMetadata = (
|
||||
artifact: WorkArtifact
|
||||
): ArtifactMetadata => {
|
||||
if (!artifact.metadataJson) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
|
||||
parseArtifactMetadata(artifact).changedFiles ?? [];
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { WebAuthProvider } from "@code/auth/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
|
||||
import "./index.css";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { useMemo } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
@@ -13,11 +10,14 @@ import {
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useNavigation,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { createFlueFetch } from "./lib/flue-transport";
|
||||
import { loadAuthToken } from "./lib/auth.server";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
||||
@@ -32,34 +32,6 @@ export const links: Route.LinksFunction = () => [
|
||||
},
|
||||
];
|
||||
|
||||
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
|
||||
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
|
||||
|
||||
const AuthenticatedFlueProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const resolveAccessToken = useConvexAccessToken();
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
headers: async () => {
|
||||
const accessToken = await resolveAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers.authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
[resolveAccessToken]
|
||||
);
|
||||
|
||||
return <FlueProvider client={client}>{children}</FlueProvider>;
|
||||
};
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -79,9 +51,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
</html>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<WebAuthProvider>
|
||||
<AuthenticatedFlueProvider>
|
||||
const RouteProgress = () => {
|
||||
const navigation = useNavigation();
|
||||
if (!navigation.location) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading page"
|
||||
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
|
||||
>
|
||||
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
|
||||
</output>
|
||||
);
|
||||
};
|
||||
|
||||
export const HydrateFallback = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu…
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
const App = ({ loaderData }: Route.ComponentProps) => (
|
||||
<WebAuthProvider initialToken={loaderData.token}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
@@ -89,10 +83,10 @@ const App = () => (
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<RouteProgress />
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</AuthenticatedFlueProvider>
|
||||
</WebAuthProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,21 +2,9 @@ import { index, layout, route } from "@react-router/dev/routes";
|
||||
import type { RouteConfig } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// Standalone chat page — no auth, mobile-first, talks to the zopu server.
|
||||
route("chat", "./routes/standalone/chat/page.tsx"),
|
||||
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("chat/:workUnitId", "./routes/app/mobile/chat/work/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"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function Dashboard() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { requireAuthToken } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
requireAuthToken(request);
|
||||
|
||||
export default function AppLayout() {
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "inconsistent") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Your session could not be verified
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "unauthenticated") {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-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 <SliceOnePage />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Work chat | Zopu" },
|
||||
{
|
||||
content: "Chat with Zopu in the context of a selected work unit",
|
||||
name: "description",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileWorkChatPage() {
|
||||
return <MobileFlowPage screen="work-chat" />;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function MobileProductLayout() {
|
||||
return (
|
||||
<div className="min-h-svh bg-[#11110f]">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function MobileLandingRedirect() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -1,123 +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 } 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
|
||||
|
||||
export default function ProjectWorkspaceRoute() {
|
||||
return <ProjectWorkspacePage />;
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { redirectAuthenticated } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
redirectAuthenticated(request);
|
||||
|
||||
export default function AuthLayout() {
|
||||
const auth = useWebAuth();
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "authenticated") {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoginForm } from "@code/auth/web";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
||||
|
||||
export default function LoginRoute() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
|
||||
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
|
||||
return (
|
||||
<LoginForm
|
||||
onSuccess={() =>
|
||||
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import { defineConfig } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
ssr: {
|
||||
noExternal: true,
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
});
|
||||
|
||||
20
deploy/dokploy/agent.Dockerfile
Normal file
20
deploy/dokploy/agent.Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM oven/bun:1.3.14 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun run --filter @code/agents build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "packages/agents/dist/server.mjs"]
|
||||
31
deploy/dokploy/backend.compose.yml
Normal file
31
deploy/dokploy/backend.compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: packages/agents/Dockerfile
|
||||
environment:
|
||||
AGENT_BACKEND_URL: ${AGENT_BACKEND_URL}
|
||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
||||
CONVEX_URL: ${CONVEX_URL}
|
||||
DAEMON_ID: zopu-agent-backend
|
||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
GITEA_URL: ${GITEA_URL:-https://git.openputer.com}
|
||||
PORT: "3000"
|
||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
||||
networks:
|
||||
- default
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
15
deploy/dokploy/frontend.compose.yml
Normal file
15
deploy/dokploy/frontend.compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
VITE_AUTH_URL: ${VITE_AUTH_URL}
|
||||
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
VITE_AUTH_URL: ${VITE_AUTH_URL}
|
||||
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
|
||||
restart: unless-stopped
|
||||
23
deploy/dokploy/runner.Dockerfile
Normal file
23
deploy/dokploy/runner.Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/zopu-source
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
|
||||
ENV BUN_EXECUTABLE=/usr/local/bin/bun
|
||||
ENV DAEMON_ID=zopu-agentos-runner
|
||||
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
||||
|
||||
VOLUME ["/var/lib/zopu/workspaces"]
|
||||
|
||||
CMD ["bun", "packages/agents/src/runner.ts"]
|
||||
26
deploy/dokploy/runner.compose.yml
Normal file
26
deploy/dokploy/runner.compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
runner:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/dokploy/runner.Dockerfile
|
||||
environment:
|
||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
||||
CONVEX_URL: ${CONVEX_URL}
|
||||
DAEMON_ID: zopu-agentos-runner
|
||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
||||
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
|
||||
volumes:
|
||||
- runner-workspaces:/var/lib/zopu/workspaces
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
runner-workspaces:
|
||||
21
deploy/dokploy/web.Dockerfile
Normal file
21
deploy/dokploy/web.Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM oven/bun:1.3.14 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun run --filter web build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/web/node_modules/.bin/react-router-serve", "apps/web/build/server/index.js"]
|
||||
@@ -12,10 +12,11 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
CONVEX_URL=https://your-deployment.convex.cloud
|
||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
||||
SITE_URL=http://localhost:5173
|
||||
SITE_URL=http://localhost:13100
|
||||
VITE_AUTH_URL=http://localhost:13100
|
||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
||||
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
|
||||
VITE_FLUE_URL=http://localhost:3583
|
||||
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||
|
||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
||||
@@ -24,14 +25,7 @@ CONVEX_INSTANCE_NAME=zopu-production
|
||||
CONVEX_INSTANCE_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
|
||||
# The agent daemon clones repos and creates PRs through Gitea.
|
||||
# ---------------------------------------------------------------------------
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=replace-with-gitea-api-token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Model gateway — REQUIRED
|
||||
# 2. Model gateway — REQUIRED
|
||||
# All model calls route through this OpenAI-compatible endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENT_MODEL_PROVIDER=cheaptricks
|
||||
@@ -43,24 +37,25 @@ AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
|
||||
# registry.start() in the daemon boots an in-process RivetKit engine
|
||||
# (envoy mode) backed by a native Rust sidecar. createClient() connects
|
||||
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
|
||||
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
|
||||
# library default (http://localhost:6420).
|
||||
# 3. AgentOS / Rivet Engine — REQUIRED for the execution runner
|
||||
# The runner creates isolated worktrees from ZOPU_SOURCE_REPOSITORY and
|
||||
# connects to AgentOS through the public engine endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
#RIVET_ENDPOINT=http://localhost:6420
|
||||
|
||||
RIVET_ENDPOINT=https://default:@rivet.example.com
|
||||
RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com
|
||||
RIVET_RUNNER_VERSION=1
|
||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
||||
ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Zopu agent service (Flue)
|
||||
# 4. Zopu agent service (Flue)
|
||||
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
|
||||
# zopu-agent.service pins the Flue Node server to port 3583.
|
||||
# ---------------------------------------------------------------------------
|
||||
FLUE_DB_TOKEN=replace-with-long-random-token
|
||||
AGENT_BACKEND_URL=https://zopu-agent.example.com
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Daemon identity
|
||||
# 5. Daemon identity
|
||||
# ---------------------------------------------------------------------------
|
||||
DAEMON_ID=zopu-dedicated
|
||||
DAEMON_NAME=Zopu-Dedicated-Server
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
||||
#
|
||||
# The web frontend (port 5173) is served at the root, and the Flue agent/API
|
||||
# (port 3585) is mounted under /api so the browser talks same-origin.
|
||||
# The web frontend (port 5173) is served at the root, Better Auth is proxied
|
||||
# first-party under /api/auth, and Flue is mounted under /api/flue.
|
||||
|
||||
zopu.cheaptricks.puter.wtf {
|
||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||
encode zstd gzip
|
||||
|
||||
handle_path /api/* {
|
||||
handle /api/auth/* {
|
||||
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
||||
header_up Host befitting-dalmatian-161.convex.site
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /api/flue/* {
|
||||
reverse_proxy 127.0.0.1:3585
|
||||
}
|
||||
|
||||
|
||||
23
deploy/zopu-runtime/runner/Dockerfile
Normal file
23
deploy/zopu-runtime/runner/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/zopu-source
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
|
||||
ENV BUN_EXECUTABLE=/usr/local/bin/bun
|
||||
ENV DAEMON_ID=zopu-agentos-runner
|
||||
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
||||
|
||||
VOLUME ["/var/lib/zopu/workspaces"]
|
||||
|
||||
CMD ["bun", "packages/agents/src/runner.ts"]
|
||||
26
deploy/zopu-runtime/runner/docker-compose.yml
Normal file
26
deploy/zopu-runtime/runner/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
zopu-agentos-runner:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: deploy/zopu-runtime/runner/Dockerfile
|
||||
environment:
|
||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
||||
CONVEX_URL: ${CONVEX_URL}
|
||||
DAEMON_ID: zopu-agentos-runner
|
||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
||||
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
|
||||
volumes:
|
||||
- zopu-agentos-workspaces:/var/lib/zopu/workspaces
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
zopu-agentos-workspaces:
|
||||
269
docs/TECH.md
269
docs/TECH.md
@@ -10,62 +10,59 @@
|
||||
## 1. System topology
|
||||
|
||||
```text
|
||||
Web / Buzz / integrations
|
||||
│
|
||||
Web / desktop / mobile
|
||||
│ Convex queries, mutations, storage
|
||||
▼
|
||||
Application API + Convex durable data
|
||||
│
|
||||
Convex application backend
|
||||
├── authentication and tenancy
|
||||
├── normalized product data
|
||||
├── conversation turn queue
|
||||
└── reactive client projections
|
||||
│ durable Workflow steps + service-authenticated dispatch
|
||||
▼
|
||||
Rivet actor system
|
||||
├── ProjectActor
|
||||
├── WorkActor
|
||||
├── AttemptActor
|
||||
├── VerificationActor (later split)
|
||||
├── IntegrationActor (later)
|
||||
└── ResultActor (later)
|
||||
│
|
||||
Private agent backend
|
||||
├── FLUE product agents and typed tools
|
||||
├── AgentOS execution environments
|
||||
├── Codex implementation harness
|
||||
└── canonical events/results returned to Convex
|
||||
│ optional attached full sandbox
|
||||
▼
|
||||
FLUE orchestration/application agents
|
||||
├── Signal/Definition
|
||||
├── Design/Slice planning
|
||||
├── Resolver decisions
|
||||
├── Verification design/evaluation
|
||||
└── Learning synthesis
|
||||
│
|
||||
▼
|
||||
Ports
|
||||
├── HarnessRuntime
|
||||
├── SandboxRuntime
|
||||
├── SourceControl
|
||||
├── ArtifactStore
|
||||
├── PreviewRuntime
|
||||
├── SecretBroker
|
||||
├── EventJournal
|
||||
└── RuntimePolicy
|
||||
│
|
||||
▼
|
||||
Adapters
|
||||
├── OMP/OpenCode/Codex/Pi/custom FLUE
|
||||
├── CubeSandbox/AgentOS/persistent machine/Docker
|
||||
├── Git forge
|
||||
├── object storage
|
||||
└── deployment/preview providers
|
||||
Cube/E2B-compatible runtime (later)
|
||||
```
|
||||
|
||||
Clients MUST communicate only with Convex. FLUE and future Rivet/AgentOS services are private workers: Convex admits durable commands, invokes the worker, and stores the product-facing result before clients observe it.
|
||||
|
||||
### Ownership
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| Work OS | durable intent, lifecycle, evidence, policy |
|
||||
| Rivet actors | serialized ownership, recovery, timers, leases |
|
||||
| FLUE | programmable orchestration and domain-specific agents |
|
||||
| --- | --- |
|
||||
| Convex | authentication, normalized product records, command admission, reactive reads |
|
||||
| Convex Workflow | durable sequencing, retries, cancellation, scheduling, and completion callbacks |
|
||||
| Agent backend | private programmable agents and execution adapters |
|
||||
| Rivet Engine + runners | placement, routing, sleep/wake, and execution ownership for AgentOS workspaces |
|
||||
| Harness | bounded coding/tool loop |
|
||||
| Sandbox/runtime | filesystem, processes, network, isolation |
|
||||
| Git | source revision history |
|
||||
| Artifact store | durable outputs/evidence |
|
||||
| External systems | collaboration, delivery, monitoring |
|
||||
|
||||
No harness, sandbox, or chat transcript owns Work state.
|
||||
No client, harness, sandbox, or FLUE process owns product state.
|
||||
|
||||
### Slice 1 relational core
|
||||
|
||||
```text
|
||||
organizations ──< organizationMembers
|
||||
organizations ──< projects ──< projectContextDocuments
|
||||
organizations ──1 conversations ──< conversationTurns
|
||||
conversationTurns ──< conversationMessages ──< conversationAttachments
|
||||
projects ──< signals ──< signalConstraints
|
||||
signals ──< signalSources >── conversationMessages
|
||||
projects ──< works
|
||||
signals ──< signalWorkAttachments >── works
|
||||
works ──< workEvents
|
||||
```
|
||||
|
||||
Convex mutations provide atomic transactions and optimistic serializability. Foreign-key integrity and uniqueness are enforced in the owning mutations; composite indexes back every identity and relation lookup. Flue's adapter tables remain isolated infrastructure persistence and are not product-domain relations.
|
||||
|
||||
## 2. Domain boundaries
|
||||
|
||||
@@ -104,93 +101,93 @@ Avoid package proliferation in v0; logical boundaries may begin as folders.
|
||||
Minimal durable model:
|
||||
|
||||
```ts
|
||||
type Id = string
|
||||
type Id = string;
|
||||
|
||||
interface Message {
|
||||
id: Id
|
||||
projectId: Id
|
||||
content: string
|
||||
createdAt: number
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface Signal {
|
||||
id: Id
|
||||
projectId: Id
|
||||
sourceType: string
|
||||
sourceId: string
|
||||
sourcePayloadRef?: string
|
||||
summary: string
|
||||
fingerprint: string
|
||||
status: "candidate" | "accepted" | "dismissed"
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
sourcePayloadRef?: string;
|
||||
summary: string;
|
||||
fingerprint: string;
|
||||
status: "candidate" | "accepted" | "dismissed";
|
||||
}
|
||||
|
||||
interface Work {
|
||||
id: Id
|
||||
projectId: Id
|
||||
title: string
|
||||
objective: string
|
||||
risk: "low" | "medium" | "high"
|
||||
status: WorkStatus
|
||||
definitionVersion?: number
|
||||
designVersion?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
title: string;
|
||||
objective: string;
|
||||
risk: "low" | "medium" | "high";
|
||||
status: WorkStatus;
|
||||
definitionVersion?: number;
|
||||
designVersion?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: Id
|
||||
workId: Id
|
||||
sliceId?: Id
|
||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
|
||||
objective: string
|
||||
dependsOn: readonly Id[]
|
||||
status: StepStatus
|
||||
id: Id;
|
||||
workId: Id;
|
||||
sliceId?: Id;
|
||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe";
|
||||
objective: string;
|
||||
dependsOn: readonly Id[];
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId: Id
|
||||
kitVersion: string
|
||||
status: RunStatus
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId: Id;
|
||||
kitVersion: string;
|
||||
status: RunStatus;
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
id: Id
|
||||
runId: Id
|
||||
number: number
|
||||
harness: string
|
||||
runtime: string
|
||||
sourceRevision: string
|
||||
status: AttemptStatus
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
id: Id;
|
||||
runId: Id;
|
||||
number: number;
|
||||
harness: string;
|
||||
runtime: string;
|
||||
sourceRevision: string;
|
||||
status: AttemptStatus;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
runId?: Id
|
||||
attemptId?: Id
|
||||
type: string
|
||||
uri?: string
|
||||
contentHash?: string
|
||||
sourceRevision?: string
|
||||
environmentId?: string
|
||||
metadata: unknown
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId?: Id;
|
||||
runId?: Id;
|
||||
attemptId?: Id;
|
||||
type: string;
|
||||
uri?: string;
|
||||
contentHash?: string;
|
||||
sourceRevision?: string;
|
||||
environmentId?: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
attemptId?: Id
|
||||
prompt: string
|
||||
recommendation?: string
|
||||
alternatives: readonly string[]
|
||||
status: "open" | "answered" | "withdrawn"
|
||||
answer?: string
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId?: Id;
|
||||
attemptId?: Id;
|
||||
prompt: string;
|
||||
recommendation?: string;
|
||||
alternatives: readonly string[];
|
||||
status: "open" | "answered" | "withdrawn";
|
||||
answer?: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -335,35 +332,49 @@ Keep domain/application code provider-neutral.
|
||||
|
||||
```ts
|
||||
interface HarnessRuntime {
|
||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
|
||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
|
||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
|
||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
|
||||
abort(id: string): Effect.Effect<void, HarnessError>
|
||||
close(id: string): Effect.Effect<void, HarnessError>
|
||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>;
|
||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>;
|
||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>;
|
||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>;
|
||||
abort(id: string): Effect.Effect<void, HarnessError>;
|
||||
close(id: string): Effect.Effect<void, HarnessError>;
|
||||
}
|
||||
|
||||
interface SandboxRuntime {
|
||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
|
||||
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
|
||||
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
|
||||
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
|
||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
|
||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>;
|
||||
exec(
|
||||
lease: SandboxLease,
|
||||
cmd: Command
|
||||
): Effect.Effect<CommandResult, SandboxError>;
|
||||
readFile(
|
||||
lease: SandboxLease,
|
||||
path: string
|
||||
): Effect.Effect<Uint8Array, SandboxError>;
|
||||
writeFile(
|
||||
lease: SandboxLease,
|
||||
path: string,
|
||||
body: Uint8Array
|
||||
): Effect.Effect<void, SandboxError>;
|
||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>;
|
||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>;
|
||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>;
|
||||
}
|
||||
|
||||
interface SourceControl {
|
||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
|
||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
|
||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
|
||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
|
||||
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
|
||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>;
|
||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>;
|
||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>;
|
||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>;
|
||||
createPullRequest(
|
||||
input: PullRequestInput
|
||||
): Effect.Effect<PullRequestArtifact, GitError>;
|
||||
}
|
||||
|
||||
interface VerificationRuntime {
|
||||
execute(plan: VerificationPlan, env: EnvironmentRef):
|
||||
Effect.Effect<VerificationResult, VerificationError>
|
||||
execute(
|
||||
plan: VerificationPlan,
|
||||
env: EnvironmentRef
|
||||
): Effect.Effect<VerificationResult, VerificationError>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -418,6 +429,10 @@ Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completi
|
||||
|
||||
## 10. Runtime strategy
|
||||
|
||||
### Convex orchestration
|
||||
|
||||
Slice 5 uses the Convex Workflow component as the durable product orchestration layer. Workflow steps call private agent-backend actions, while all user-visible state, events, artifacts, idempotency keys, and cancellation remain canonical in Convex.
|
||||
|
||||
### CubeSandbox
|
||||
|
||||
Best for full Linux execution:
|
||||
@@ -441,7 +456,7 @@ Best for:
|
||||
- actor-adjacent orchestration;
|
||||
- context/files/networking that fit runtime limits.
|
||||
|
||||
Use an attached full sandbox when native/heavy tooling is needed.
|
||||
Slice 5 starts here with one Codex-backed AgentOS actor and one authenticated repository checkout per project. Convex Workflow owns product orchestration; Rivet Engine coordinates placement while a normal runner executes the actor. Use an attached full sandbox through the E2B-compatible boundary when native/heavy tooling is needed.
|
||||
|
||||
### Persistent project machine
|
||||
|
||||
|
||||
@@ -211,16 +211,18 @@ Zopu implements one approved slice in an isolated repository environment and str
|
||||
Initial adapter choice:
|
||||
|
||||
```text
|
||||
SandboxRuntime = CubeSandboxLive
|
||||
HarnessRuntime = OmpHarnessLive (or one chosen harness)
|
||||
SandboxRuntime = AgentOsSandboxLive
|
||||
HarnessRuntime = CodexHarnessLive
|
||||
Durable orchestration = Convex Workflow
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
prepare worktree
|
||||
→ create sandbox
|
||||
→ clone/mount repo
|
||||
load the project's authenticated Git connection
|
||||
→ start durable Convex workflow
|
||||
→ create AgentOS execution environment
|
||||
→ clone the single configured repo
|
||||
→ inject context
|
||||
→ run one slice
|
||||
→ normalize events
|
||||
@@ -230,14 +232,15 @@ prepare worktree
|
||||
|
||||
Security:
|
||||
|
||||
- scoped Git/model tokens;
|
||||
- GitHub OAuth or self-hosted Gitea PAT, scoped to one project;
|
||||
- scoped Git/model tokens passed only to private execution;
|
||||
- isolated HOME/worktree;
|
||||
- one mutating attempt per worktree;
|
||||
- timeout/cancel cleanup.
|
||||
|
||||
### Frontend
|
||||
|
||||
Current activity, changed files, artifact links, expandable raw logs.
|
||||
Project selector/settings, Git connection status, current activity, changed files, artifact links, expandable raw logs, cancel/retry, and manual Git delivery controls.
|
||||
|
||||
### Acceptance
|
||||
|
||||
@@ -247,6 +250,8 @@ Current activity, changed files, artifact links, expandable raw logs.
|
||||
- provider failure becomes classified attempt outcome;
|
||||
- exact base/candidate revision recorded.
|
||||
|
||||
Rivet Engine coordinates AgentOS actor placement through a normal runner, but does not own product orchestration; Convex Workflow remains canonical for the Run lifecycle. Cube/Kubernetes sandbox support remains behind `SandboxRuntime` and can be mounted through AgentOS incrementally.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Independent verification and repair
|
||||
@@ -490,4 +495,4 @@ browser-tester integration-coordinator
|
||||
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12
|
||||
```
|
||||
|
||||
Parallel engineering is allowed *within* a slice after contracts land, but product release order remains sequential.
|
||||
Parallel engineering is allowed _within_ a slice after contracts land, but product release order remains sequential.
|
||||
|
||||
@@ -5,5 +5,28 @@ import remix from "ultracite/oxlint/remix";
|
||||
|
||||
export default defineConfig({
|
||||
extends: [core, react, remix],
|
||||
ignorePatterns: core.ignorePatterns,
|
||||
ignorePatterns: [...core.ignorePatterns, "repos/**", "scripts/**"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/convex/**/*.ts", "**/convex/**/*.test.ts"],
|
||||
rules: {
|
||||
"unicorn/filename-case": "off",
|
||||
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
|
||||
"unicorn/no-array-sort": "off",
|
||||
"no-await-in-loop": "off",
|
||||
// Convex query builders use `any` in index callbacks
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"eslint/complexity": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/convex/**/*.test.ts"],
|
||||
rules: {
|
||||
"unicorn/no-await-expression-member": "off",
|
||||
"eslint/require-await": "off",
|
||||
"sort-keys": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
66
package.json
66
package.json
@@ -1,53 +1,14 @@
|
||||
{
|
||||
"name": "code",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"apps/web",
|
||||
"packages/agents",
|
||||
"packages/auth",
|
||||
"packages/backend",
|
||||
"packages/config",
|
||||
"packages/env",
|
||||
"packages/primitives",
|
||||
"packages/ui",
|
||||
"packages/ui"
|
||||
],
|
||||
"catalog": {
|
||||
"@rivet-dev/agentos": "^0.2.7",
|
||||
"@rivet-dev/agentos-core": "^0.2.10",
|
||||
"@effect/platform-bun": "4.0.0-beta.99",
|
||||
"dotenv": "^17.4.2",
|
||||
"zod": "^4.4.3",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"sonner": "^2.0.7",
|
||||
"convex": "^1.42.1",
|
||||
"better-auth": "1.6.15",
|
||||
"@convex-dev/better-auth": "^0.12.5",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"@better-auth/expo": "1.6.15",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^6",
|
||||
"@types/bun": "latest",
|
||||
"heroui-native": "^1.0.5",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.10",
|
||||
"convex-test": "^0.0.54"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vp run -r dev",
|
||||
"build": "vp run -r build",
|
||||
"check-types": "vp run -r check-types",
|
||||
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat/chat-attachments.tsx apps/web/src/components/chat/chat-message.tsx apps/web/src/components/slice-one apps/web/src/hooks/chat/use-chat-agent.test.ts apps/web/src/hooks/chat/use-chat-images.ts apps/web/src/hooks/slice-one apps/web/src/lib/chat/attachments.ts apps/web/src/lib/chat/attachments.test.ts apps/web/src/lib/chat/transforms.ts apps/web/src/lib/chat/transforms.test.ts apps/web/src/lib/flue-transport.ts apps/web/src/lib/flue-transport.test.ts apps/web/src/lib/slice-one apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/chat/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/schema.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/primitives/src/work.ts packages/primitives/src/work.test.ts",
|
||||
"lint": "vp lint",
|
||||
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
|
||||
"lint": "oxlint --disable-nested-config",
|
||||
"format": "vp fmt",
|
||||
"smoke:zopu": "bun scripts/zopu-smoke.ts",
|
||||
"staged": "vp staged",
|
||||
"hooks:setup": "vp config",
|
||||
"dev:web": "vp run --filter web dev",
|
||||
@@ -57,16 +18,10 @@
|
||||
"dev:server": "vp run --filter @code/backend dev",
|
||||
"dev:setup": "vp run --filter @code/backend dev:setup",
|
||||
"build:agents": "vp run --filter @code/agents build",
|
||||
"docs:update": "bun run scripts/update-docs.ts",
|
||||
"subtree": "bun run scripts/subtree.ts",
|
||||
"fix": "ultracite fix",
|
||||
"slice1": "vp run -r dev",
|
||||
"orb:proof": "bun run scripts/orb-proof.ts",
|
||||
"orb:run": "bun run scripts/orb-project-run.ts",
|
||||
"dev:zopu": "vp run --filter @code/agents dev",
|
||||
"dev:zopu:web": "vp run --filter web dev"
|
||||
"docs:update": "node scripts/update-docs.ts",
|
||||
"subtree": "node scripts/subtree.ts",
|
||||
"fix": "ultracite fix"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/config": "workspace:*",
|
||||
@@ -74,20 +29,17 @@
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/node": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"convex-test": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"oxfmt": "latest",
|
||||
"oxlint": "latest",
|
||||
"oxfmt": "0.61.0",
|
||||
"oxlint": "1.76.0",
|
||||
"rolldown": "1.1.4",
|
||||
"typescript": "catalog:",
|
||||
"ultracite": "7.9.3",
|
||||
"vite-plus": "0.2.2",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
|
||||
},
|
||||
"packageManager": "bun@1.3.14"
|
||||
"packageManager": "pnpm@11.17.0"
|
||||
}
|
||||
|
||||
32
packages/agents/Dockerfile
Normal file
32
packages/agents/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN node packages/agents/node_modules/@flue/cli/bin/flue.mjs build --target node --root packages/agents
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV NODE_OPTIONS=--experimental-specifier-resolution=node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app /app
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "packages/agents/dist/server.mjs"]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from '@flue/cli/config';
|
||||
import { defineConfig } from "@flue/cli/config";
|
||||
|
||||
export default defineConfig({
|
||||
target: 'node',
|
||||
target: "node",
|
||||
});
|
||||
|
||||
@@ -4,34 +4,37 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --env-file=../../.env flue build",
|
||||
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
|
||||
"start": "node --env-file=../../.env dist/server.mjs",
|
||||
"check-types": "tsc --noEmit",
|
||||
"dev": "bun --env-file=../../.env flue dev",
|
||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
||||
"run": "bun --env-file=../../.env flue run",
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu",
|
||||
"run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev"
|
||||
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
|
||||
"runner": "node --env-file=../../.env src/runner.ts",
|
||||
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
|
||||
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/opencode": "0.2.7",
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos-core": "catalog:",
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"@rivet-dev/agentos-core": "0.2.14",
|
||||
"convex": "catalog:",
|
||||
"dockerode": "^5.0.1",
|
||||
"effect": "catalog:",
|
||||
"get-port": "^7.2.0",
|
||||
"hono": "4.12.31",
|
||||
"sandbox-agent": "0.4.2",
|
||||
"valibot": "^1.4.2"
|
||||
"hono": "catalog:",
|
||||
"rivetkit": "2.3.9",
|
||||
"valibot": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@flue/cli": "latest",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18 <23 || >=23.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Smoke test: exercise the agentos sandbox adapter through Flue's
|
||||
* createSandboxSessionEnv wrapper — the same path the zopu agent uses.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-adapter-test.ts
|
||||
*/
|
||||
import { agentos } from "../src/sandboxes/agentos";
|
||||
|
||||
const main = async () => {
|
||||
const factory = agentos();
|
||||
|
||||
// Flue calls this once per harness initialization.
|
||||
const env = await factory.createSessionEnv({ id: "smoke-test" });
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const assert = (label: string, condition: boolean) => {
|
||||
if (condition) {
|
||||
pass += 1;
|
||||
console.log(` ✓ ${label}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(` ✗ ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("\n=== File operations ===");
|
||||
|
||||
await env.writeFile("test.txt", "hello from Flue adapter");
|
||||
assert("writeFile", true);
|
||||
|
||||
const content = await env.readFile("test.txt");
|
||||
assert(
|
||||
"readFile returns written content",
|
||||
content === "hello from Flue adapter"
|
||||
);
|
||||
|
||||
const stat = await env.stat("test.txt");
|
||||
assert("stat isFile", stat.isFile === true);
|
||||
assert("stat size", stat.size === "hello from Flue adapter".length);
|
||||
|
||||
assert("exists true", (await env.exists("test.txt")) === true);
|
||||
assert("exists false for missing", (await env.exists("nope.txt")) === false);
|
||||
|
||||
console.log("\n=== Directory operations ===");
|
||||
|
||||
await env.mkdir("subdir", { recursive: true });
|
||||
await env.writeFile("subdir/a.ts", "export const a = 1;");
|
||||
await env.writeFile("subdir/b.ts", "export const b = 2;");
|
||||
const entries = await env.readdir("subdir");
|
||||
assert(
|
||||
"readdir returns names",
|
||||
entries.length === 2 && entries.includes("a.ts")
|
||||
);
|
||||
|
||||
await env.rm("subdir/b.ts");
|
||||
const after = await env.readdir("subdir");
|
||||
assert("rm removes file", after.length === 1 && after[0] === "a.ts");
|
||||
|
||||
await env.rm("subdir", { recursive: true });
|
||||
assert("rm recursive removes dir", (await env.exists("subdir")) === false);
|
||||
|
||||
console.log("\n=== Shell exec ===");
|
||||
|
||||
const echoRes = await env.exec("echo 'adapter shell works'");
|
||||
assert("exec echo exit code", echoRes.exitCode === 0);
|
||||
assert("exec echo stdout", echoRes.stdout.trim() === "adapter shell works");
|
||||
|
||||
const pipeRes = await env.exec("echo 'hello' | tr a-z A-Z");
|
||||
assert("exec pipe", pipeRes.stdout.trim() === "HELLO");
|
||||
|
||||
const lsRes = await env.exec("ls", { cwd: "/workspace" });
|
||||
assert("exec with cwd", lsRes.stdout.includes("test.txt"));
|
||||
|
||||
const envRes = await env.exec("echo $TEST_VAR", {
|
||||
env: { TEST_VAR: "injected" },
|
||||
});
|
||||
assert("exec with env", envRes.stdout.trim() === "injected");
|
||||
|
||||
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
if (fail > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Reference: agentOS VM as a sandbox backend for Flue.
|
||||
*
|
||||
* This script demonstrates the core idea behind a Flue sandbox adapter that
|
||||
* delegates file/shell operations to an agentOS VM. It does NOT wire into the
|
||||
* Flue agent yet — it proves the primitives work end-to-end so you can see
|
||||
* exactly how the adapter will map calls.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-sandbox-ref.ts
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* Flue agent (host) agentOS VM (isolated Wasm+V8)
|
||||
* ┌─────────────────────┐ ┌──────────────────────┐
|
||||
* │ defineAgent(...) │ │ Linux-like sandbox │
|
||||
* │ sandbox: agentOs()│── readFile ──► │ persistent FS │
|
||||
* │ │── writeFile ─► │ shell (sh, coreutils)│
|
||||
* │ │── exec ──────► │ ~6ms cold start │
|
||||
* └─────────────────────┘ └──────────────────────┘
|
||||
*
|
||||
* The Flue SandboxApi maps 1:1 to AgentOs methods:
|
||||
*
|
||||
* Flue SandboxApi AgentOs (core SDK)
|
||||
* ───────────────────────── ──────────────────────────
|
||||
* readFile(path) vm.readFile(path)
|
||||
* readFileBuffer(path) vm.readFile(path)
|
||||
* writeFile(path, content) vm.writeFile(path, content)
|
||||
* stat(path) vm.stat(path)
|
||||
* readdir(path) vm.readdir(path)
|
||||
* exists(path) vm.exists(path)
|
||||
* mkdir(path) vm.mkdir(path)
|
||||
* rm(path) vm.remove(path)
|
||||
* exec(command, opts) vm.exec(command, opts)
|
||||
*/
|
||||
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { VirtualStat } from "@rivet-dev/agentos-core";
|
||||
|
||||
const formatStat = (stat: VirtualStat): string =>
|
||||
JSON.stringify({
|
||||
isDirectory: stat.isDirectory,
|
||||
isSymbolicLink: stat.isSymbolicLink,
|
||||
mtime: new Date(stat.mtimeMs).toISOString(),
|
||||
size: stat.size,
|
||||
});
|
||||
|
||||
const main = async () => {
|
||||
console.log("=== Booting agentOS VM ===\n");
|
||||
|
||||
// AgentOs.create() boots a local VM with the default software bundle
|
||||
// (sh + coreutils). The VM sleeps when idle and wakes on the next action.
|
||||
const vm = await AgentOs.create();
|
||||
console.log("VM booted.\n");
|
||||
|
||||
// ── File operations ──────────────────────────────────────────────
|
||||
|
||||
console.log("=== writeFile ===");
|
||||
await vm.writeFile("/workspace/hello.txt", "Hello from agentOS VM!");
|
||||
console.log("wrote /workspace/hello.txt\n");
|
||||
|
||||
console.log("=== readFile ===");
|
||||
const content = await vm.readFile("/workspace/hello.txt");
|
||||
console.log("content:", new TextDecoder().decode(content), "\n");
|
||||
|
||||
console.log("=== stat ===");
|
||||
const stat = await vm.stat("/workspace/hello.txt");
|
||||
console.log("stat:", formatStat(stat), "\n");
|
||||
|
||||
console.log("=== exists ===");
|
||||
console.log(
|
||||
"exists /workspace/hello.txt:",
|
||||
await vm.exists("/workspace/hello.txt")
|
||||
);
|
||||
console.log(
|
||||
"exists /workspace/nope.txt:",
|
||||
await vm.exists("/workspace/nope.txt"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
console.log("=== mkdir + readdir ===");
|
||||
await vm.mkdir("/workspace/src", { recursive: true });
|
||||
await vm.writeFile("/workspace/src/a.ts", "export const a = 1;");
|
||||
await vm.writeFile("/workspace/src/b.ts", "export const b = 2;");
|
||||
console.log(
|
||||
"readdir /workspace/src:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Shell exec ───────────────────────────────────────────────────
|
||||
|
||||
console.log("=== exec: echo ===");
|
||||
const echoResult = await vm.exec("echo 'shell works!'");
|
||||
console.log(
|
||||
"stdout:",
|
||||
echoResult.stdout.trim(),
|
||||
`(exit ${echoResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: pipe + coreutils ===");
|
||||
const pipeResult = await vm.exec("echo 'hello world' | tr a-z A-Z | wc -w");
|
||||
console.log(
|
||||
"stdout:",
|
||||
pipeResult.stdout.trim(),
|
||||
`(exit ${pipeResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: with cwd + env ===");
|
||||
await vm.mkdir("/workspace/project", { recursive: true });
|
||||
await vm.writeFile("/workspace/project/file.txt", "data");
|
||||
const cwdResult = await vm.exec("pwd && ls", {
|
||||
cwd: "/workspace/project",
|
||||
env: { CUSTOM_VAR: "from-env" },
|
||||
});
|
||||
console.log(
|
||||
"stdout:",
|
||||
cwdResult.stdout.trim(),
|
||||
`(exit ${cwdResult.exitCode})\n`
|
||||
);
|
||||
|
||||
// ── Remove ───────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== remove ===");
|
||||
await vm.remove("/workspace/src/b.ts");
|
||||
console.log(
|
||||
"after remove, readdir:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
await vm.dispose();
|
||||
console.log("=== VM disposed. All primitives work. ===");
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
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";
|
||||
import { AgentOsSandboxApi } from "../sandboxes/agent-os";
|
||||
import {
|
||||
clonePublicRepository,
|
||||
mirrorSandboxToHostCheckout,
|
||||
} from "../sandboxes/host-repository-bridge";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const createHostGitRunner = () => ({
|
||||
async run(
|
||||
command: string,
|
||||
options?: { cwd?: string; env?: Record<string, string> }
|
||||
) {
|
||||
try {
|
||||
const result = await execAsync(command, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
stderr: String(result.stderr),
|
||||
stdout: String(result.stdout),
|
||||
};
|
||||
} catch (error) {
|
||||
const failure = error as Error & {
|
||||
readonly code?: number;
|
||||
readonly stderr?: string;
|
||||
readonly stdout?: string;
|
||||
};
|
||||
return {
|
||||
exitCode:
|
||||
typeof failure.code === "number" && failure.code > 0
|
||||
? failure.code
|
||||
: 1,
|
||||
stderr: failure.stderr ?? failure.message,
|
||||
stdout: failure.stdout ?? "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const makeAuthenticatedRepositoryUrl = (
|
||||
repositoryUrl: string,
|
||||
repositoryPath: string,
|
||||
token: string
|
||||
): string => {
|
||||
const [owner] = repositoryPath.split("/");
|
||||
if (!owner) {
|
||||
throw new Error("Gitea repository path has no owner");
|
||||
}
|
||||
const url = new URL(repositoryUrl);
|
||||
url.username = owner;
|
||||
url.password = token;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
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({ 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"
|
||||
);
|
||||
}
|
||||
if (!context.run) {
|
||||
throw new Error("Project work run is not initialized");
|
||||
}
|
||||
|
||||
const sandbox = new AgentOsSandboxApi(
|
||||
client,
|
||||
env.DAEMON_ID,
|
||||
context.run.actorKey
|
||||
);
|
||||
const checkout = await clonePublicRepository({
|
||||
branchName:
|
||||
context.run.branchName ?? `work/issue-${context.issue.number}`,
|
||||
repositoryUrl: context.source.url,
|
||||
});
|
||||
const runner = createHostGitRunner();
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: env.GITEA_URL,
|
||||
token: env.GITEA_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
await mirrorSandboxToHostCheckout({
|
||||
checkoutDirectory: checkout.checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: context.run.checkoutPath ?? "/workspace/repository",
|
||||
});
|
||||
await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"remote",
|
||||
"set-url",
|
||||
"origin",
|
||||
makeAuthenticatedRepositoryUrl(
|
||||
context.source.url,
|
||||
context.source.repositoryPath,
|
||||
env.GITEA_TOKEN
|
||||
),
|
||||
],
|
||||
{
|
||||
cwd: checkout.checkoutDirectory,
|
||||
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
||||
}
|
||||
);
|
||||
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: checkout.checkoutDirectory,
|
||||
});
|
||||
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: checkout.checkoutDirectory,
|
||||
});
|
||||
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;
|
||||
} finally {
|
||||
await checkout.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
16
packages/agents/src/admission-context.ts
Normal file
16
packages/agents/src/admission-context.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export interface TurnAdmissionContext {
|
||||
readonly clientRequestId: string;
|
||||
readonly turnId: string;
|
||||
}
|
||||
|
||||
const turnAdmissionContext = new AsyncLocalStorage<TurnAdmissionContext>();
|
||||
|
||||
export const currentTurnAdmission = (): TurnAdmissionContext | undefined =>
|
||||
turnAdmissionContext.getStore();
|
||||
|
||||
export const withTurnAdmission = <T>(
|
||||
context: TurnAdmissionContext,
|
||||
run: () => T
|
||||
): T => turnAdmissionContext.run(context, run);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
export const description =
|
||||
"Works one repository issue inside an isolated AgentOS workspace.";
|
||||
|
||||
export const route: AgentRouteHandler = (_context, next) => next();
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
actions: [createFinalizeGiteaLifecycle(id, env)],
|
||||
cwd: "/workspace/repository",
|
||||
description,
|
||||
instructions: `You are the issue-scoped project manager and coding agent.
|
||||
|
||||
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. The repository checkout is the current working directory; inspect existing source files before changing them. Git metadata stays on the trusted host bridge, so do not run git commands inside AgentOS—the final lifecycle action mirrors verified files into the host checkout before committing and pushing. 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 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}`,
|
||||
sandbox: agentOs(env),
|
||||
tools: createProjectTools(id, env),
|
||||
};
|
||||
});
|
||||
30
packages/agents/src/agents/work-planner.ts
Normal file
30
packages/agents/src/agents/work-planner.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
|
||||
import { createWorkPlannerTools } from "../tools/work-planner";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu's private Work Planner.
|
||||
|
||||
You may only submit typed proposals through tools:
|
||||
- a Work Definition proposal;
|
||||
- a Design Packet proposal containing 1-4 observable, independently verifiable slices;
|
||||
- structured questions.
|
||||
|
||||
You must never approve a Definition or Design, change Work status, start execution, claim implementation, create Git changes, or claim that simulation implemented the Work. Convex validates and stores every proposal. Ask a focused structured question when a high-impact decision is unresolved.`;
|
||||
|
||||
export {
|
||||
convexAgentRoute as attachments,
|
||||
convexAgentRoute as route,
|
||||
} from "../auth";
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
return {
|
||||
description:
|
||||
"Proposes Work Definitions, Design Packets, and structured questions.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
thinkingLevel: "medium",
|
||||
tools: createWorkPlannerTools(env),
|
||||
};
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import { createGitRemoteTools, makeGitRemoteConfig } from "../tools/git-remote";
|
||||
import { createWorkflowTools } from "../tools/workflow";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu Dev, the development agent for the canonical zopu-code repository (self-hosted Gitea: puter/zopu-code).
|
||||
|
||||
## Your job
|
||||
|
||||
You talk with the user and turn agreed-upon work into tracked issues, then kick off autonomous implementation. You operate on exactly one repository.
|
||||
|
||||
## Loop
|
||||
|
||||
1. Understand the request. Ask one focused clarifying question only when genuinely ambiguous. Do not create work from casual chat.
|
||||
|
||||
2. Check for duplicates. Call list_issues to see what already exists.
|
||||
|
||||
3. Create the issue. Call create_issue with a clear title and a body that captures the acceptance criteria. Report the issue number and URL back to the user.
|
||||
|
||||
4. Start work only on explicit confirmation. When the user says to start (e.g. "go", "start", "work on it"), call start_workflow with the issue number. This spins up a Codex agent in an isolated worktree that implements, verifies, commits, and opens a pull request.
|
||||
|
||||
5. Report outcomes plainly: "Created issue #N: <title> — <url>" and "Started run <id> (status <status>)".
|
||||
|
||||
## Rules
|
||||
|
||||
- Never invent issue numbers. Always create or list first.
|
||||
- Never start work without explicit user confirmation.
|
||||
- Titles are concise; bodies carry the acceptance criteria.
|
||||
- If a tool returns an error with a reason, surface it and suggest next steps instead of retrying blindly.`;
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
const agentEnv = parseAgentEnv(env);
|
||||
const gitConfig = makeGitRemoteConfig(agentEnv);
|
||||
|
||||
return {
|
||||
description:
|
||||
"Development agent that creates issues on the canonical zopu-code repo and starts autonomous Codex work runs.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${agentEnv.AGENT_MODEL_PROVIDER}/${agentEnv.AGENT_MODEL_NAME}`,
|
||||
sandbox: local({ cwd: process.cwd() }),
|
||||
tools: [
|
||||
...createGitRemoteTools(gitConfig),
|
||||
...createWorkflowTools({
|
||||
convexUrl: agentEnv.CONVEX_URL,
|
||||
token: agentEnv.FLUE_DB_TOKEN,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -1,56 +1,12 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
|
||||
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
|
||||
import { createSliceOneTools } from "../tools/slice-one";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu for product Slice 1: Conversation to Signal to proposed Work.
|
||||
|
||||
## Your role
|
||||
|
||||
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
|
||||
|
||||
## Work routing loop
|
||||
|
||||
When a user sends a message, follow this decision flow:
|
||||
|
||||
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
|
||||
|
||||
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
|
||||
|
||||
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
|
||||
|
||||
3. **Create a Signal (only when actionable).** When the message is actionable:
|
||||
a. Call list_signal_evidence to see the exact admitted user messages.
|
||||
b. Select the message IDs that compose the problem statement.
|
||||
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
|
||||
d. Include the projectId when the project is known.
|
||||
|
||||
4. **Route the Signal.** After creating the Signal:
|
||||
a. Call list_proposed_work for the project.
|
||||
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
|
||||
c. Otherwise call create_work_from_signal.
|
||||
e. If genuinely uncertain whether to attach or create, ask one focused question.
|
||||
|
||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||
- "Captured [Signal title] and linked it to [Work title]."
|
||||
- "Captured [Signal title] and proposed [Work title]."
|
||||
- Keep the response brief; the product renders the durable Work card separately.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
|
||||
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
|
||||
- Never create Work from casual chat.
|
||||
- Ask at most one focused clarification when genuinely ambiguous.
|
||||
- Preserve project and organization scope at all times.
|
||||
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
|
||||
- Never claim you created a Signal until the tool call returns successfully.
|
||||
- Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
|
||||
- Proposed Work is the only Work status in this slice.`;
|
||||
|
||||
export {
|
||||
authenticatedAgentRoute as attachments,
|
||||
authenticatedAgentRoute as route,
|
||||
convexAgentRoute as attachments,
|
||||
convexAgentRoute as route,
|
||||
} from "../auth";
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
|
||||
import { registerProvider } from "@flue/runtime";
|
||||
import type { Fetchable } from "@flue/runtime/routing";
|
||||
import { flue } from "@flue/runtime/routing";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { projectRequestRoute } from "./project-request";
|
||||
import {
|
||||
cancelAgentOsAttempt,
|
||||
executeAgentOsAttempt,
|
||||
runtimeRegistry,
|
||||
} from "./runtime/agent-os";
|
||||
|
||||
const agentEnv = parseAgentEnv(process.env);
|
||||
|
||||
@@ -27,7 +33,54 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.post("/project-requests", projectRequestRoute);
|
||||
app.route("/", flue() as unknown as Hono);
|
||||
|
||||
export default app;
|
||||
app.post("/internal/work-attempts/execute", async (context) => {
|
||||
if (
|
||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
||||
) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
try {
|
||||
return context.json(await executeAgentOsAttempt(await context.req.json()));
|
||||
} catch (error) {
|
||||
const failure =
|
||||
error instanceof WorkAttemptExecutionError
|
||||
? error
|
||||
: new WorkAttemptExecutionError({
|
||||
message:
|
||||
error instanceof Error ? error.message : "Execution failed",
|
||||
reason: "HarnessFailed",
|
||||
retryable: false,
|
||||
});
|
||||
return context.json(
|
||||
{
|
||||
error: {
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
|
||||
if (
|
||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
||||
) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const body = (await context.req.json()) as { attemptId?: unknown };
|
||||
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
|
||||
return context.json({ error: "attemptId is required" }, 400);
|
||||
}
|
||||
await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
|
||||
return context.json({ cancelled: true });
|
||||
});
|
||||
|
||||
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
|
||||
|
||||
app.route("/", flue());
|
||||
|
||||
export default app satisfies Fetchable;
|
||||
|
||||
@@ -1,321 +1,30 @@
|
||||
/**
|
||||
* Authenticated Flue route middleware for the organization-scoped global agent.
|
||||
*
|
||||
* Every request to the Zopu agent routes through here. The middleware:
|
||||
*
|
||||
* 1. Requires a Bearer access token (the Better Auth/Convex JWT).
|
||||
* 2. Creates a fresh, request-scoped {@link ConvexHttpClient} and sets the
|
||||
* token on it — never on a shared/global client. Auth is per-request.
|
||||
* 3. Resolves (ensuring) the caller's current personal organization.
|
||||
* 4. Requires the agent instance id (`:id`) to equal that organization id, so
|
||||
* one organization cannot use or observe another's agent instance.
|
||||
* 5. Authorizes GET/SSE observation routes as strictly as POST.
|
||||
* 6. For POST only: captures exact user-message evidence *before* Flue
|
||||
* admission (`beginUserMessage`, status `admitting`), then patches it to
|
||||
* `admitted` with the Flue receipt submission id, or `failed` on error.
|
||||
* Observation requests (GET/HEAD) never create evidence.
|
||||
*
|
||||
* The organization id, conversation id, message id, role, and timestamp are
|
||||
* never trusted from the client or an agent tool — they are resolved
|
||||
* server-side. Only the raw message text travels from the user, and it is
|
||||
* stored verbatim (never trimmed or rewritten).
|
||||
*/
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed function references (no generated backend bindings imported).
|
||||
// ---------------------------------------------------------------------------
|
||||
import { withTurnAdmission } from "./admission-context";
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: string;
|
||||
readonly _creationTime: number;
|
||||
readonly name: string;
|
||||
readonly kind: "personal" | "team";
|
||||
readonly createdBy: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
|
||||
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
|
||||
const env = parseAgentEnv(process.env);
|
||||
const authorization = context.req.header("authorization");
|
||||
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
interface ConversationMessageView {
|
||||
readonly _id: string;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: string;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly clientRequestId: string;
|
||||
readonly role: "user";
|
||||
readonly rawText: string;
|
||||
readonly status: "admitting" | "admitted" | "failed";
|
||||
readonly createdAt: number;
|
||||
}
|
||||
const organizationId = context.req.header("x-zopu-organization-id");
|
||||
if (!organizationId || organizationId !== context.req.param("id")) {
|
||||
return context.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const ensurePersonalOrganization = makeFunctionReference<
|
||||
"mutation",
|
||||
Record<string, never>,
|
||||
OrganizationView
|
||||
>("organizations:ensurePersonalOrganization");
|
||||
if (context.req.method !== "POST") {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const beginUserMessage = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly rawText: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:beginUserMessage");
|
||||
const clientRequestId = context.req.header("x-zopu-request-id");
|
||||
const turnId = context.req.header("x-zopu-turn-id");
|
||||
if (!clientRequestId || !turnId) {
|
||||
return context.json({ error: "Missing turn correlation headers" }, 400);
|
||||
}
|
||||
|
||||
const markAdmitted = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly submissionId: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:markAdmitted");
|
||||
|
||||
const markFailed = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:markFailed");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-scoped authenticated Convex client.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { CONVEX_URL } = parseAgentEnv(process.env);
|
||||
|
||||
/**
|
||||
* A short-lived authenticated Convex client bound to one HTTP request. The
|
||||
* 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.
|
||||
*/
|
||||
export const createAuthenticatedClient = (
|
||||
accessToken: string
|
||||
): ConvexHttpClient => {
|
||||
const client = new ConvexHttpClient(CONVEX_URL);
|
||||
client.setAuth(accessToken);
|
||||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal structural shape the header helpers read from an incoming request.
|
||||
* Using this avoids a structural clash between the global (Bun) `Request`/
|
||||
* `Headers` and the undici types that Hono's context exposes, while staying
|
||||
* type-safe.
|
||||
*/
|
||||
interface HeaderSource {
|
||||
readonly headers: {
|
||||
readonly get: (name: string) => string | 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.
|
||||
*/
|
||||
export const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const match = /^Bearer\s+(?<token>\S+)$/u.exec(header);
|
||||
return match?.groups?.token ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the stable per-send request id supplied by the web client. This is
|
||||
* generated once when the user sends a message and reused across fetch/stream
|
||||
* retries, so it is a reliable idempotency key for evidence capture.
|
||||
*/
|
||||
const extractClientRequestId = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("x-zopu-request-id");
|
||||
if (!header || header.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return header;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely parse the direct agent payload to extract the exact user message
|
||||
* text. Mirrors Flue's `DirectAgentPayload` shape `{ message, images? }`.
|
||||
* Returns null for non-JSON or missing `message` so the request is rejected
|
||||
* with 400 rather than crashing the middleware.
|
||||
*/
|
||||
const extractMessageText = async (
|
||||
request: HeaderSource & { readonly json: () => Promise<unknown> }
|
||||
): Promise<string | null> => {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("message" in body) ||
|
||||
typeof (body as { message?: unknown }).message !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (body as { message: string }).message;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the Flue admission receipt submission id from the response Flue
|
||||
* produced after `next()`. The response body is `202 { streamUrl, offset,
|
||||
* submissionId }`. Returns null when the body has no submission id (e.g. an
|
||||
* error response).
|
||||
*/
|
||||
const extractSubmissionId = async (
|
||||
response: HeaderSource & {
|
||||
readonly clone: () => { readonly json: () => Promise<unknown> };
|
||||
}
|
||||
): Promise<string | null> => {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"submissionId" in body &&
|
||||
typeof (body as { submissionId?: unknown }).submissionId === "string"
|
||||
) {
|
||||
return (body as { submissionId: string }).submissionId;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON or unreadable body: no submission id to record.
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admit a direct submission through Flue and record the admission outcome on
|
||||
* the normalized evidence row. On admission failure, the evidence is marked
|
||||
* failed and the original error is rethrown — never swallowed. On success,
|
||||
* the Flue receipt submission id (if present) patches the evidence to
|
||||
* admitted.
|
||||
*/
|
||||
const admitAndRecordEvidence = async (
|
||||
continueAdmission: () => Promise<void>,
|
||||
client: ConvexHttpClient,
|
||||
getResponse: () => Response | undefined,
|
||||
evidence: {
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await continueAdmission();
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.mutation(markFailed, evidence);
|
||||
} catch {
|
||||
// Best-effort: the original error is the primary concern.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Patch the evidence with the Flue receipt submission id. If the response
|
||||
// carries no submission id (unexpected), leave the evidence in its begun
|
||||
// state rather than guessing.
|
||||
const response = getResponse();
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
const submissionId = await extractSubmissionId(response);
|
||||
if (!submissionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.mutation(markAdmitted, {
|
||||
...evidence,
|
||||
submissionId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: the admission already succeeded and the response is
|
||||
// finalized. A failure to patch metadata must not break the request.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The authenticated Flue route middleware. Authorizes every method, and for
|
||||
* POST captures normalized user-message evidence around Flue admission.
|
||||
*/
|
||||
export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
|
||||
const accessToken = extractBearerToken(c.req.raw);
|
||||
if (!accessToken) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const client = createAuthenticatedClient(accessToken);
|
||||
|
||||
// Resolve (ensuring) the caller's current personal organization.
|
||||
let organization: OrganizationView;
|
||||
try {
|
||||
organization = await client.mutation(ensurePersonalOrganization, {});
|
||||
} catch {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// The agent instance id must be the caller's organization id. This is the
|
||||
// hard tenancy boundary: one organization cannot use or observe another's
|
||||
// agent instance.
|
||||
const instanceId = c.req.param("id") ?? "";
|
||||
if (instanceId !== organization._id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Observation routes (GET/HEAD) are authorized but never create evidence.
|
||||
if (c.req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
// --- Evidence capture (POST only) -------------------------------------
|
||||
|
||||
const clientRequestId = extractClientRequestId(c.req.raw);
|
||||
const messageText = await extractMessageText(c.req.raw.clone());
|
||||
|
||||
if (!clientRequestId) {
|
||||
return c.json({ error: "Missing x-zopu-request-id" }, 400);
|
||||
}
|
||||
if (messageText === null) {
|
||||
return c.json({ error: "Invalid message payload" }, 400);
|
||||
}
|
||||
|
||||
// Begin evidence before Flue admission so the message is durable even if
|
||||
// admission fails or the process is interrupted.
|
||||
try {
|
||||
await client.mutation(beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
rawText: messageText,
|
||||
});
|
||||
} catch {
|
||||
return c.json({ error: "Evidence capture failed" }, 500);
|
||||
}
|
||||
|
||||
// Admit through Flue. After next(), c.res holds the admission response.
|
||||
// Evidence is patched from the admission receipt, or marked failed without
|
||||
// swallowing the original error.
|
||||
await admitAndRecordEvidence(next, client, () => c.res, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
});
|
||||
return c.res;
|
||||
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
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 origin 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,307 +0,0 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
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 origin 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",
|
||||
};
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
# Orb Runtime
|
||||
|
||||
One logical execution workspace for one ProjectIssue run. An Orb bundles an AgentOS actor, an OpenCode harness session, a Docker sandbox, a mounted repository workspace, project context, model-gateway configuration, process/log handling, normalized execution events, and a cleanup lifecycle.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ OrbRuntime │
|
||||
│ creates OrbHandle instances │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ OrbHandle │
|
||||
│ ┌─────────────┐ ┌────────────────────────┐ │
|
||||
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
|
||||
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
|
||||
│ │ ACP agent) │ │ in a Docker container)│ │
|
||||
│ └─────────────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ session events runProcess / │
|
||||
│ → normalized createProcess │
|
||||
│ OrbEvent │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside a Docker container hosting a sandbox-agent server. The `DockerSandboxProvider` calls `SandboxAgent.start({ sandbox: docker(...) })`, which starts the server in a Docker container with a dynamically-mapped host port and returns a `SandboxAgent` client whose `baseUrl` both the main process and the AgentOS sidecar subprocess reach over `127.0.0.1`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
|
||||
|
||||
## Domain model
|
||||
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `OrbId` | Branded identifier for one Orb |
|
||||
| `OrbRunId` | Branded identifier for one run attempt |
|
||||
| `OrbSessionId` | Branded identifier for one OpenCode session |
|
||||
| `OrbIdentity` | `{ projectId, runId, workUnitId }` — derives the actor key |
|
||||
| `OrbState` | `creating → prepared → running → needs-input → completed/failed/cancelled → disposed` |
|
||||
| `RunState` | `queued → provisioning → preparing → running → verifying → succeeded/failed/cancelled` |
|
||||
| `OrbEvent` | Normalized, secret-free execution event |
|
||||
|
||||
Orb state and run state are separate state machines. The VM/sandbox lease state is never conflated with the product work-unit state.
|
||||
|
||||
## Tagged errors
|
||||
|
||||
| Error | Reasons |
|
||||
| --- | --- |
|
||||
| `OrbStateError` | `InvalidTransition`, `AlreadyDisposed`, `NotRunning` |
|
||||
| `OrbSandboxError` | `DockerUnavailable`, `ContainerStart`, `CommandFailed`, `ContainerCleanup` |
|
||||
| `OrbSessionError` | `OpenSession`, `PromptFailed`, `AgentNotInstalled`, `SessionNotFound` |
|
||||
| `OrbConfigurationError` | `MissingGateway`, `MissingIdentity`, `InvalidModelConfig` |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `ORB_PROOF` | Proof only | Set to `1` to run the proof fixture |
|
||||
| `ORB_GATEWAY_API_KEY` | Proof only | Model gateway API key |
|
||||
| `ORB_GATEWAY_BASE_URL` | Proof only | Model gateway base URL (OpenAI-compatible `/v1`) |
|
||||
| `ORB_GATEWAY_MODEL` | Proof only | Model name |
|
||||
| `ORB_GATEWAY_PROVIDER` | Proof only | Provider identifier |
|
||||
| `ORB_DOCKER_IMAGE` | Optional | Docker image (default: `oven/bun:1.3-debian`) |
|
||||
| `ORB_DOCKER_WORKSPACE` | Optional | Host workspace root (default: `/tmp/orb-workspaces`) |
|
||||
| `RIVET_ENDPOINT` | Optional | AgentOS/RivetKit sidecar endpoint |
|
||||
|
||||
No permanent provider credentials are stored in project files. API keys are injected at runtime via the OpenCode config written to the VM filesystem, and all event output is passed through `redactSecrets`.
|
||||
|
||||
## Docker requirements
|
||||
|
||||
- Docker daemon running and accessible via the Docker socket (`/var/run/docker.sock`)
|
||||
- The sandbox uses `rivetdev/sandbox-agent` as its Docker image by default
|
||||
- The `sandbox-agent/docker` provider creates containers with `AutoRemove` and a dynamically allocated host port
|
||||
- Writable bind mount is the project workspace only (mounted at `/home/sandbox` inside the container)
|
||||
- The AgentOS sidecar subprocess reaches the sandbox-agent server over `127.0.0.1:<hostPort>`
|
||||
|
||||
## Local startup
|
||||
|
||||
```bash
|
||||
# Run the proof fixture
|
||||
ORB_PROOF=1 \
|
||||
ORB_GATEWAY_API_KEY=your-key \
|
||||
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
||||
ORB_GATEWAY_MODEL=glm-5.2 \
|
||||
ORB_GATEWAY_PROVIDER=cheaptricks \
|
||||
bun run scripts/orb-proof.ts
|
||||
```
|
||||
|
||||
Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_PROOF_FAILED` (exit 1).
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
```
|
||||
SandboxAgent container (/home/sandbox = host bind mount)
|
||||
/home/sandbox/repository/ — project checkout
|
||||
/home/sandbox/control/ — issue + context files
|
||||
|
||||
AgentOS VM (host process)
|
||||
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
|
||||
/mnt/sandbox/repository/ — repo (via sandbox)
|
||||
/root/.config/opencode/ — OpenCode config (chmod 600)
|
||||
```
|
||||
|
||||
## Current limitations
|
||||
|
||||
- No automatic merge or production deployment capability.
|
||||
- No multi-region support.
|
||||
- Interactive PTY sessions are not wired through the sandbox agent.
|
||||
- The model turn stage depends on a reachable OpenAI-compatible gateway; if the gateway is unreachable the proof reports BLOCKED at that stage but still passes Docker and AgentOS/OpenCode.
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context pack — assembled from ProjectIssue, evidence, project docs, artifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ContextFile = Schema.Struct({
|
||||
content: Schema.String,
|
||||
label: Schema.String,
|
||||
});
|
||||
export type ContextFile = typeof ContextFile.Type;
|
||||
|
||||
export const ContextPackInput = Schema.Struct({
|
||||
artifacts: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
),
|
||||
contextFiles: Schema.Array(ContextFile),
|
||||
evidence: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
source: Schema.String,
|
||||
})
|
||||
),
|
||||
issueBody: Schema.String,
|
||||
issueNumber: Schema.Int,
|
||||
issueTitle: Schema.String,
|
||||
repositoryMetadata: Schema.Struct({
|
||||
baseBranch: Schema.String,
|
||||
repositoryName: Schema.String,
|
||||
repositoryUrl: Schema.String,
|
||||
}),
|
||||
});
|
||||
export type ContextPackInput = typeof ContextPackInput.Type;
|
||||
|
||||
const section = (heading: string, lines: readonly string[]): string => {
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `## ${heading}\n\n${lines.join("\n")}`;
|
||||
};
|
||||
|
||||
const OPERATIONAL_INSTRUCTIONS = `## Operational Instructions
|
||||
|
||||
You are working inside an isolated sandbox on a dedicated work branch. The repository checkout is your working directory.
|
||||
|
||||
1. Inspect existing source before changing anything.
|
||||
2. Implement the complete issue scope. Run the project's test or verification command.
|
||||
3. If you need human input to proceed safely, emit exactly one line starting with \`NEEDS_INPUT:\` followed by your question, then stop.
|
||||
4. When implementation and verification are complete, emit exactly one line starting with \`WORK_COMPLETE:\` followed by a one-sentence summary, then stop.
|
||||
5. Do not merge, deploy, or push. The orchestrator handles the Git lifecycle after you signal completion.
|
||||
6. Never claim a change you did not observe. Preserve command evidence.`;
|
||||
|
||||
/**
|
||||
* Build a concise context pack prompt for the OpenCode session. The pack is
|
||||
* sent as the first task and includes: issue details, project docs, evidence,
|
||||
* prior artifacts, repository metadata, and operational instructions with the
|
||||
* needs-input / work-complete marker protocol.
|
||||
*/
|
||||
export const buildContextPack = (input: ContextPackInput): string => {
|
||||
const parts: string[] = [
|
||||
`# Issue #${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}`,
|
||||
];
|
||||
|
||||
if (input.contextFiles.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Project Context",
|
||||
input.contextFiles.map(
|
||||
(file) => `### ${file.label}\n\n\`\`\`\n${file.content}\n\`\`\``
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.evidence.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Supporting Evidence",
|
||||
input.evidence.map((item) => `- **${item.source}**: ${item.content}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.artifacts.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Previous Work Artifacts",
|
||||
input.artifacts.map(
|
||||
(artifact) =>
|
||||
`### ${artifact.path}\n\n\`\`\`\n${artifact.content}\n\`\`\``
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
section("Repository", [
|
||||
`- Name: ${input.repositoryMetadata.repositoryName}`,
|
||||
`- URL: ${input.repositoryMetadata.repositoryUrl}`,
|
||||
`- Base branch: ${input.repositoryMetadata.baseBranch}`,
|
||||
]),
|
||||
OPERATIONAL_INSTRUCTIONS
|
||||
);
|
||||
|
||||
return parts.filter((part) => part.length > 0).join("\n\n---\n\n");
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
/* eslint-disable no-console -- diagnostic skip messages in a Docker-guarded suite */
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as sleepTimer } from "node:timers/promises";
|
||||
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it as vitestIt } from "vitest";
|
||||
|
||||
import { DockerSandboxProvider } from "./docker-sandbox";
|
||||
import { OrbSandboxError } from "./domain";
|
||||
|
||||
// Real containers need more than the 5s default; bind a generous timeout here.
|
||||
const it = (name: string, fn: () => Promise<void>): void => {
|
||||
vitestIt(name, fn, 60_000);
|
||||
};
|
||||
|
||||
// Node-compatible Docker availability check.
|
||||
const runCliExit = (args: readonly string[]): Promise<number | null> =>
|
||||
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise
|
||||
new Promise((resolve) => {
|
||||
const [command = "docker", ...rest] = args;
|
||||
const proc = spawn(command, rest, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
proc.on("error", () => resolve(null));
|
||||
proc.on("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
const dockerAvailable = async (): Promise<boolean> => {
|
||||
try {
|
||||
const code = await runCliExit([
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
]);
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Top-level await so describe.skipIf evaluates Docker availability at registration.
|
||||
const HAVE_DOCKER = await dockerAvailable();
|
||||
|
||||
const tmpDir = (): string =>
|
||||
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
|
||||
|
||||
describe.skipIf(!HAVE_DOCKER)(
|
||||
"DockerSandboxProvider (SandboxAgent + Docker)",
|
||||
() => {
|
||||
it("starts a SandboxAgent server and exposes a reachable baseUrl", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
try {
|
||||
const client = await provider.start();
|
||||
expect(provider.isStarted).toBe(true);
|
||||
|
||||
// The SandboxAgent client must have a baseUrl property — this is what
|
||||
// AgentOS serializes so the sidecar can reach the sandbox.
|
||||
const { baseUrl } = client as unknown as { baseUrl: string };
|
||||
expect(baseUrl).toBeTruthy();
|
||||
expect(baseUrl).toMatch(/^https?:\/\//u);
|
||||
|
||||
// Run a command to verify the sandbox-agent server actually works.
|
||||
const result = await client.runProcess({
|
||||
args: ["-c", "echo hello-orb"],
|
||||
command: "sh",
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("hello-orb");
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent: start returns the same client on repeated calls", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
try {
|
||||
const c1 = await provider.start();
|
||||
const c2 = await provider.start();
|
||||
expect(c1).toBe(c2);
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("disposes cleanly and frees the Docker container", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
await provider.start();
|
||||
expect(provider.isStarted).toBe(true);
|
||||
|
||||
await provider.dispose();
|
||||
expect(provider.isStarted).toBe(false);
|
||||
|
||||
// Give AutoRemove a moment.
|
||||
// eslint-disable-next-line no-await-in-loop -- single settling wait
|
||||
await sleepTimer(2000);
|
||||
|
||||
// Second dispose is a safe no-op.
|
||||
await expect(provider.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds the host workspace into the sandbox container", async () => {
|
||||
const workspace = tmpDir();
|
||||
const { spawn: nodeSpawn } = await import("node:child_process");
|
||||
// eslint-disable-next-line promise/avoid-new -- one-shot shell write
|
||||
await new Promise<void>((resolve) => {
|
||||
const p = nodeSpawn(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
`mkdir -p ${workspace} && echo proof-file > ${workspace}/marker.txt`,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
p.on("close", () => resolve());
|
||||
});
|
||||
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: workspace,
|
||||
})
|
||||
);
|
||||
try {
|
||||
const client = await provider.start();
|
||||
const result = await client.runProcess({
|
||||
args: ["-c", "cat /home/sandbox/marker.txt"],
|
||||
command: "sh",
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("proof-file");
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
|
||||
it("create fails with DockerUnavailable", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
Effect.flip(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: "/tmp/orb-test-nodocker",
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbSandboxError);
|
||||
expect(error.reason).toBe("DockerUnavailable");
|
||||
});
|
||||
});
|
||||
|
||||
if (!HAVE_DOCKER) {
|
||||
console.warn(
|
||||
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user