Compare commits
55 Commits
feat/auth-
...
zopu/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb7c95b94b | ||
|
|
9f7dd3c1a6 | ||
|
|
a8b2ff5e2e | ||
|
|
40e0f7e1eb | ||
|
|
bf2300a6be | ||
|
|
0657037c84 | ||
|
|
64a783b445 | ||
|
|
ed28943e7a | ||
|
|
9e148489f0 | ||
|
|
25f86d94cc | ||
|
|
7668fa69cc | ||
|
|
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://
|
NATIVE_APP_URL=code://
|
||||||
|
|
||||||
# Browser and native public endpoints
|
# Browser and native public endpoints
|
||||||
|
VITE_AUTH_URL=http://localhost:5173
|
||||||
VITE_CONVEX_URL=https://example.convex.cloud
|
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_URL=https://example.convex.cloud
|
||||||
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
||||||
|
|
||||||
@@ -19,10 +17,13 @@ DAEMON_NAME=Local MacBook
|
|||||||
DAEMON_VERSION=0.0.0
|
DAEMON_VERSION=0.0.0
|
||||||
DAEMON_HEARTBEAT_MS=15000
|
DAEMON_HEARTBEAT_MS=15000
|
||||||
DAEMON_COMMAND_LEASE_MS=60000
|
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 persistence adapter
|
||||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||||
|
FLUE_URL=http://localhost:3583
|
||||||
|
|
||||||
# Agent model provider
|
# Agent model provider
|
||||||
AGENT_MODEL_PROVIDER=xiaomi
|
AGENT_MODEL_PROVIDER=xiaomi
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,3 +54,4 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
|
.env.*
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||||
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||||
"files.exclude": {
|
|
||||||
"repos/**": true
|
|
||||||
},
|
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"repos/**": true
|
"repos/**": true
|
||||||
},
|
},
|
||||||
|
|||||||
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/env": "workspace:*",
|
||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@code/ui": "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/fs-routes": "^8.1.0",
|
||||||
"@react-router/node": "^8.1.0",
|
"@react-router/node": "^8.1.0",
|
||||||
"@react-router/serve": "^8.1.0",
|
"@react-router/serve": "^8.1.0",
|
||||||
@@ -24,18 +22,18 @@
|
|||||||
"isbot": "^5.1.44",
|
"isbot": "^5.1.44",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "catalog:",
|
"next-themes": "catalog:",
|
||||||
"react": "^19.2.7",
|
"react": "catalog:",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "catalog:",
|
||||||
"react-router": "^8.1.0",
|
"react-router": "^8.1.0",
|
||||||
"sonner": "catalog:",
|
"sonner": "catalog:",
|
||||||
"streamdown": "2.5.0"
|
"streamdown": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
"@react-router/dev": "^8.1.0",
|
"@react-router/dev": "^8.1.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"react-router-devtools": "^6.2.1",
|
"react-router-devtools": "^6.2.1",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
|
||||||
ssr: false,
|
|
||||||
appDirectory: "src",
|
appDirectory: "src",
|
||||||
|
|
||||||
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import {
|
|||||||
AttachmentMedia,
|
AttachmentMedia,
|
||||||
AttachmentTitle,
|
AttachmentTitle,
|
||||||
} from "@code/ui/components/attachment";
|
} from "@code/ui/components/attachment";
|
||||||
import type { FlueConversationPart } from "@flue/react";
|
|
||||||
import { ImageIcon, X } from "lucide-react";
|
import { ImageIcon, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
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 =>
|
const isDirectlyRenderableUrl = (url: string): boolean =>
|
||||||
url.startsWith("blob:") || url.startsWith("data:");
|
url.startsWith("blob:") || url.startsWith("data:");
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
MobileChatBubble,
|
MobileChatBubble,
|
||||||
MobileChatMessage,
|
MobileChatMessage,
|
||||||
} from "@code/ui/components/mobile-chat";
|
} from "@code/ui/components/mobile-chat";
|
||||||
import type { FlueConversationMessage } from "@flue/react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMessageText,
|
getMessageText,
|
||||||
getReasoningText,
|
getReasoningText,
|
||||||
hasToolActivity,
|
|
||||||
isReasoningStreaming,
|
isReasoningStreaming,
|
||||||
isMessageStreaming,
|
isMessageStreaming,
|
||||||
} from "@/lib/chat/transforms";
|
} from "@/lib/chat/transforms";
|
||||||
@@ -23,7 +21,6 @@ import type {
|
|||||||
|
|
||||||
import { AssistantIdentity } from "./assistant-identity";
|
import { AssistantIdentity } from "./assistant-identity";
|
||||||
import { MessageAttachments } from "./chat-attachments";
|
import { MessageAttachments } from "./chat-attachments";
|
||||||
import { ChatToolCall } from "./chat-tool-call";
|
|
||||||
|
|
||||||
const ReasoningTrace = ({
|
const ReasoningTrace = ({
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -96,27 +93,7 @@ const assistantState = (
|
|||||||
return isStreaming ? "writing" : undefined;
|
return isStreaming ? "writing" : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolActivity = ({
|
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||||
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) => {
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isStreaming = isMessageStreaming(message);
|
const isStreaming = isMessageStreaming(message);
|
||||||
const text = getMessageText(message);
|
const text = getMessageText(message);
|
||||||
@@ -124,16 +101,6 @@ export const ChatMessage = ({
|
|||||||
const reasoningStreaming = isReasoningStreaming(message);
|
const reasoningStreaming = isReasoningStreaming(message);
|
||||||
const fileParts = message.parts.filter((part) => part.type === "file");
|
const fileParts = message.parts.filter((part) => part.type === "file");
|
||||||
|
|
||||||
if (
|
|
||||||
hideToolActivity &&
|
|
||||||
!isUser &&
|
|
||||||
hasToolActivity(message) &&
|
|
||||||
!text &&
|
|
||||||
!reasoning
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.parts.length === 0 && !isStreaming) {
|
if (message.parts.length === 0 && !isStreaming) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -167,7 +134,6 @@ export const ChatMessage = ({
|
|||||||
text={reasoning}
|
text={reasoning}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ToolActivity hidden={hideToolActivity} message={message} />
|
|
||||||
{content}
|
{content}
|
||||||
</MobileChatBubble>
|
</MobileChatBubble>
|
||||||
</div>
|
</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 { api } from "@code/backend/convex/_generated/api";
|
||||||
import { useFlueAgent } from "@flue/react";
|
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 type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
import { projectConversation } from "@/lib/chat/conversation";
|
||||||
import type { ChatAgentState } from "@/lib/chat/types";
|
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 = (
|
export const useOrganizationChatAgent = (
|
||||||
organization: PersonalOrganizationState
|
organization: PersonalOrganizationState
|
||||||
): ChatAgentState => {
|
): ChatAgentState => {
|
||||||
const agent = useFlueAgent({
|
const { organizationId } = organization;
|
||||||
...CHAT_AGENT,
|
const rows = useQuery(
|
||||||
id: organization.organizationId,
|
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 (
|
const sendMessage = async (
|
||||||
message: string,
|
message: string,
|
||||||
options?: SendMessageOptions
|
options?: { readonly images?: readonly File[] }
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!organization.organizationId) {
|
if (!organizationId) {
|
||||||
throw (
|
throw (
|
||||||
organization.error ??
|
organization.error ??
|
||||||
new Error("Personal organization is still being prepared")
|
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 {
|
return {
|
||||||
error: organization.error ?? agent.error,
|
error:
|
||||||
historyReady: agent.historyReady,
|
organization.error ??
|
||||||
messages: agent.messages,
|
sendError ??
|
||||||
|
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||||
|
historyReady: rows !== undefined,
|
||||||
|
messages: projected.messages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
status,
|
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 { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||||
import type { PendingChatImage } 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 = () => {
|
export const useChatImages = () => {
|
||||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||||
@@ -40,7 +43,7 @@ export const useChatImages = () => {
|
|||||||
previewUrls.current.add(previewUrl);
|
previewUrls.current.add(previewUrl);
|
||||||
next.push({
|
next.push({
|
||||||
file,
|
file,
|
||||||
id: generateBrowserRequestId(),
|
id: generateImageId(),
|
||||||
previewUrl,
|
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 { useWebAuth } from "@code/auth/web";
|
||||||
import { api } from "@code/backend/convex/_generated/api";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
import { useMutation } from "convex/react";
|
import { useConvexAuth, useMutation } from "convex/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface PersonalOrganizationState {
|
export interface PersonalOrganizationState {
|
||||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
|||||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||||
const auth = useWebAuth();
|
const auth = useWebAuth();
|
||||||
|
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||||
|
const convexReady = isAuthenticated && !isRefreshing;
|
||||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||||
const ensurePersonalOrganization = useMutation(
|
const ensurePersonalOrganization = useMutation(
|
||||||
api.organizations.ensurePersonalOrganization
|
api.organizations.ensurePersonalOrganization
|
||||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) {
|
if (!convexReady || !userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [ensurePersonalOrganization, userId]);
|
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||||
|
|
||||||
if (!userId || state?.userId !== userId) {
|
if (!convexReady || !userId || state?.userId !== userId) {
|
||||||
return {};
|
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";
|
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", () => {
|
test("shrinks the application surface to the keyboard-visible height", () => {
|
||||||
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
|
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
|
||||||
height: "500px",
|
height: "500px",
|
||||||
@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
root.classList.add("slice-one-viewport-lock");
|
root.classList.add("workspace-viewport-lock");
|
||||||
update();
|
update();
|
||||||
window.addEventListener("resize", update);
|
window.addEventListener("resize", update);
|
||||||
viewport?.addEventListener("resize", update);
|
viewport?.addEventListener("resize", update);
|
||||||
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(animationFrame);
|
cancelAnimationFrame(animationFrame);
|
||||||
root.classList.remove("slice-one-viewport-lock");
|
root.classList.remove("workspace-viewport-lock");
|
||||||
window.removeEventListener("resize", update);
|
window.removeEventListener("resize", update);
|
||||||
viewport?.removeEventListener("resize", update);
|
viewport?.removeEventListener("resize", update);
|
||||||
viewport?.removeEventListener("scroll", update);
|
viewport?.removeEventListener("scroll", update);
|
||||||
@@ -6,8 +6,8 @@ body {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.slice-one-viewport-lock,
|
html.workspace-viewport-lock,
|
||||||
html.slice-one-viewport-lock body {
|
html.workspace-viewport-lock body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
|
|||||||
min-height: 1.75rem;
|
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 {
|
@keyframes response-dot {
|
||||||
0%,
|
0%,
|
||||||
60%,
|
60%,
|
||||||
@@ -103,9 +116,42 @@ html.slice-one-viewport-lock body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slice-one-surface .chat-markdown,
|
/*
|
||||||
.slice-one-surface .chat-reasoning,
|
* The chat always renders on the light workspace surface
|
||||||
.slice-one-surface .thinking-line {
|
* (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
|
||||||
|
* app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
|
||||||
|
* the rules below read theme tokens, which under `.dark` resolve to near-black
|
||||||
|
* values — producing unreadable dark-on-dark blocks. Re-declare the relevant
|
||||||
|
* tokens to warm-light values inside the markdown context so every token-driven
|
||||||
|
* chrome (headers, copy/download/fullscreen controls, table borders/cells,
|
||||||
|
* inline code) reads correctly on the light surface without touching the
|
||||||
|
* global theme or unrelated UI.
|
||||||
|
*/
|
||||||
|
.workspace-surface .chat-markdown {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #232321;
|
||||||
|
--muted: #f4f4f2;
|
||||||
|
--muted-foreground: #69675f;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #232321;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #232321;
|
||||||
|
--secondary: #f4f4f2;
|
||||||
|
--secondary-foreground: #232321;
|
||||||
|
--accent: #efece4;
|
||||||
|
--accent-foreground: #232321;
|
||||||
|
--sidebar: #ffffff;
|
||||||
|
--sidebar-foreground: #232321;
|
||||||
|
--sidebar-accent: #f4f4f2;
|
||||||
|
--sidebar-accent-foreground: #232321;
|
||||||
|
--border: #e2e0d6;
|
||||||
|
--input: #e2e0d6;
|
||||||
|
--ring: #b6b7b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-surface .chat-markdown,
|
||||||
|
.workspace-surface .chat-reasoning,
|
||||||
|
.workspace-surface .thinking-line {
|
||||||
color: #232321;
|
color: #232321;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +256,16 @@ html.slice-one-viewport-lock body {
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Readable header band + zebra rows so tables stand out on the surface. */
|
||||||
|
.chat-markdown thead th {
|
||||||
|
background: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-markdown tbody tr:nth-child(even) {
|
||||||
|
background: color-mix(in oklch, var(--muted) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.chat-markdown *,
|
.chat-markdown *,
|
||||||
.chat-message,
|
.chat-message,
|
||||||
|
|||||||
63
apps/web/src/lib/auth.server.ts
Normal file
63
apps/web/src/lib/auth.server.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
interface AuthLoaderData {
|
||||||
|
readonly token: string | null;
|
||||||
|
}
|
||||||
|
export const loadAuthToken = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return { token: null };
|
||||||
|
}
|
||||||
|
const tokenUrl = new URL(
|
||||||
|
"/api/auth/convex/token",
|
||||||
|
new URL(request.url).origin
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({ cookie });
|
||||||
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
|
||||||
MAX_CHAT_IMAGE_BYTES,
|
|
||||||
bytesToBase64,
|
|
||||||
validateChatImage,
|
|
||||||
} from "./attachments";
|
|
||||||
|
|
||||||
describe("chat image attachments", () => {
|
describe("chat image attachments", () => {
|
||||||
test("accepts supported image payloads within the upload limit", () => {
|
test("accepts supported image payloads within the upload limit", () => {
|
||||||
@@ -24,8 +20,4 @@ describe("chat image attachments", () => {
|
|||||||
}).accepted
|
}).accepted
|
||||||
).toBe(false);
|
).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_IMAGES = 4;
|
||||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -28,20 +26,3 @@ export const validateChatImage = (
|
|||||||
}
|
}
|
||||||
return { accepted: true };
|
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
|
* The global Zopu agent. The instance `id` is the current organization id
|
||||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||||
* instance. Organization scoping is the hard tenancy boundary.
|
* 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_ID = "openrouter/openai/gpt-oss-20b:free";
|
||||||
export const MODEL_LABEL = "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> = {
|
export const STATUS_COPY: Record<AgentStatus, string> = {
|
||||||
connecting: "Connecting",
|
connecting: "Connecting",
|
||||||
error: "Connection issue",
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
extractThinkingMarkup,
|
extractThinkingMarkup,
|
||||||
getReasoningText,
|
getReasoningText,
|
||||||
hasToolActivity,
|
|
||||||
isReasoningStreaming,
|
isReasoningStreaming,
|
||||||
} from "./transforms";
|
} from "./transforms";
|
||||||
|
import type { ConversationMessage } from "./types";
|
||||||
|
|
||||||
const message = (
|
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
|
||||||
parts: FlueConversationMessage["parts"]
|
|
||||||
): FlueConversationMessage => ({
|
|
||||||
id: "message-1",
|
id: "message-1",
|
||||||
parts,
|
parts,
|
||||||
role: "assistant",
|
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", () => {
|
describe("reasoning traces", () => {
|
||||||
test("extracts completed and streaming MiniMax think blocks", () => {
|
test("extracts completed and streaming MiniMax think blocks", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
|
import type { AgentStatus, ConversationMessage } from "./types";
|
||||||
|
|
||||||
export const stripThinkingMarkup = (text: string): string => {
|
export const stripThinkingMarkup = (text: string): string => {
|
||||||
const withoutCompletedBlocks = text.replaceAll(
|
const withoutCompletedBlocks = text.replaceAll(
|
||||||
@@ -33,16 +33,16 @@ export const extractThinkingMarkup = (text: string): string => {
|
|||||||
return completed.join("\n\n");
|
return completed.join("\n\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRawMessageText = (message: FlueConversationMessage): string =>
|
export const getRawMessageText = (message: ConversationMessage): string =>
|
||||||
message.parts
|
message.parts
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => part.text)
|
.map((part) => part.text)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
export const getMessageText = (message: FlueConversationMessage): string =>
|
export const getMessageText = (message: ConversationMessage): string =>
|
||||||
stripThinkingMarkup(getRawMessageText(message));
|
stripThinkingMarkup(getRawMessageText(message));
|
||||||
|
|
||||||
export const getReasoningText = (message: FlueConversationMessage): string => {
|
export const getReasoningText = (message: ConversationMessage): string => {
|
||||||
const nativeReasoning = message.parts
|
const nativeReasoning = message.parts
|
||||||
.filter((part) => part.type === "reasoning")
|
.filter((part) => part.type === "reasoning")
|
||||||
.map((part) => part.text)
|
.map((part) => part.text)
|
||||||
@@ -51,9 +51,7 @@ export const getReasoningText = (message: FlueConversationMessage): string => {
|
|||||||
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
|
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isReasoningStreaming = (
|
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
|
||||||
message: FlueConversationMessage
|
|
||||||
): boolean => {
|
|
||||||
const nativeReasoningStreaming = message.parts.some(
|
const nativeReasoningStreaming = message.parts.some(
|
||||||
(part) => part.type === "reasoning" && part.state === "streaming"
|
(part) => part.type === "reasoning" && part.state === "streaming"
|
||||||
);
|
);
|
||||||
@@ -66,19 +64,12 @@ export const isReasoningStreaming = (
|
|||||||
return nativeReasoningStreaming || inlineReasoningStreaming;
|
return nativeReasoningStreaming || inlineReasoningStreaming;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasToolActivity = (message: FlueConversationMessage): boolean =>
|
export const isMessageStreaming = (message: ConversationMessage): boolean =>
|
||||||
message.parts.some((part) => part.type === "dynamic-tool");
|
message.parts.some(
|
||||||
|
(part) =>
|
||||||
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
|
|
||||||
message.parts.some((part) => {
|
|
||||||
if (part.type === "dynamic-tool") {
|
|
||||||
return part.state === "input-available";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
(part.type === "text" || part.type === "reasoning") &&
|
(part.type === "text" || part.type === "reasoning") &&
|
||||||
part.state === "streaming"
|
part.state === "streaming"
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
export const getStatusDotClass = (status: AgentStatus): string => {
|
export const getStatusDotClass = (status: AgentStatus): string => {
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
import type {
|
export type AgentStatus =
|
||||||
AgentPromptImage,
|
| "connecting"
|
||||||
AgentStatus,
|
| "error"
|
||||||
FlueConversationMessage,
|
| "idle"
|
||||||
FlueConversationPart,
|
| "streaming"
|
||||||
} from "@flue/react";
|
| "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 {
|
export interface ChatAgentState {
|
||||||
error?: Error;
|
error?: Error;
|
||||||
historyReady: boolean;
|
historyReady: boolean;
|
||||||
messages: FlueConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
message: string,
|
message: string,
|
||||||
options?: { readonly images?: AgentPromptImage[] }
|
options?: { readonly images?: readonly File[] }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
@@ -24,7 +49,7 @@ export interface ChatComposerProps {
|
|||||||
|
|
||||||
export interface ChatConversationProps {
|
export interface ChatConversationProps {
|
||||||
historyReady: boolean;
|
historyReady: boolean;
|
||||||
messages: FlueConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
onSuggestion: (suggestion: string) => void;
|
onSuggestion: (suggestion: string) => void;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
@@ -34,12 +59,7 @@ export interface ChatHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageProps {
|
export interface ChatMessageProps {
|
||||||
hideToolActivity?: boolean;
|
message: ConversationMessage;
|
||||||
message: FlueConversationMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatToolCallProps {
|
|
||||||
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssistantResponseState = "thinking" | "writing";
|
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) =>
|
const source = (relativePath: string) =>
|
||||||
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
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", () => {
|
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 root = source("../../root.tsx");
|
||||||
const styles = source("../../index.css");
|
const styles = source("../../index.css");
|
||||||
|
|
||||||
expect(root).toContain("interactive-widget=resizes-content");
|
expect(root).toContain("interactive-widget=resizes-content");
|
||||||
expect(page).toContain("style={viewportStyle}");
|
expect(page).toContain("style={viewportStyle}");
|
||||||
expect(page).toContain("fixed inset-x-0 top-0");
|
expect(page).toContain("fixed inset-x-0 top-0");
|
||||||
expect(page).not.toContain("slice-one-surface flex h-svh");
|
expect(viewport).toContain("workspace-viewport-lock");
|
||||||
expect(styles).toContain("html.slice-one-viewport-lock body");
|
expect(styles).toContain("html.workspace-viewport-lock body");
|
||||||
expect(styles).toContain("overflow: hidden");
|
expect(styles).toContain("overflow: hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the responsive shell shrinkable with a pinned composer", () => {
|
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('className="flex min-w-0 flex-1 flex-col"');
|
||||||
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
|
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
|
||||||
expect(page).toContain('className="shrink-0 border-t');
|
expect(composer).toContain('className="shrink-0 border-t');
|
||||||
expect(page).toContain(
|
expect(composer).toContain(
|
||||||
'className="mx-auto flex max-w-2xl items-end gap-2"'
|
'className="mx-auto flex max-w-2xl items-end gap-2"'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -50,4 +59,20 @@ describe("Slice 1 frontend regression contracts", () => {
|
|||||||
expect(messageRenderer).toContain("mermaid");
|
expect(messageRenderer).toContain("mermaid");
|
||||||
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
|
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps markdown readable on the forced-dark workspace surface", () => {
|
||||||
|
const styles = source("../../index.css");
|
||||||
|
|
||||||
|
// The workspace surface is light, but the app forces the dark theme, so
|
||||||
|
// the markdown context must re-declare the theme tokens streamdown and the
|
||||||
|
// table/code rules read from. Without these the dark tokens (~22-28%
|
||||||
|
// lightness) produce near-black text, borders, and backgrounds.
|
||||||
|
expect(styles).toContain(".workspace-surface .chat-markdown");
|
||||||
|
expect(styles).toContain("--muted: #f4f4f2");
|
||||||
|
expect(styles).toContain("--border: #e2e0d6");
|
||||||
|
expect(styles).toContain("--sidebar: #ffffff");
|
||||||
|
// Table headers and zebra rows must stand out on the surface.
|
||||||
|
expect(styles).toContain(".chat-markdown thead th");
|
||||||
|
expect(styles).toContain(".chat-markdown tbody tr:nth-child(even)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import type { WorkNotice } from "@code/primitives/work";
|
import type { WorkNotice } from "@code/primitives/work";
|
||||||
import type { FlueConversationMessage } from "@flue/react";
|
|
||||||
import { describe, expect, test } from "vitest";
|
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 = (
|
const textMessage = (
|
||||||
id: string,
|
id: string,
|
||||||
role: "assistant" | "user",
|
role: "assistant" | "user",
|
||||||
text: string
|
text: string
|
||||||
): FlueConversationMessage => ({
|
): ConversationMessage => ({
|
||||||
id,
|
id,
|
||||||
parts: [{ state: "done", text, type: "text" }],
|
parts: [{ state: "done", text, type: "text" }],
|
||||||
role,
|
role,
|
||||||
@@ -22,24 +26,11 @@ const notice: WorkNotice = {
|
|||||||
workId: "work-1",
|
workId: "work-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Slice 1 presentation", () => {
|
describe("Workspace presentation", () => {
|
||||||
test("places proposed Work after the assistant response to its source", () => {
|
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."),
|
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."),
|
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
|
||||||
],
|
],
|
||||||
[notice]
|
[notice]
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
import type { WorkNotice } from "@code/primitives/work";
|
import type { WorkNotice } from "@code/primitives/work";
|
||||||
import type { FlueConversationMessage } from "@flue/react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMessageText,
|
getMessageText,
|
||||||
getRawMessageText,
|
getRawMessageText,
|
||||||
getReasoningText,
|
getReasoningText,
|
||||||
hasToolActivity,
|
|
||||||
} from "@/lib/chat/transforms";
|
} from "@/lib/chat/transforms";
|
||||||
|
import type { ConversationMessage } from "@/lib/chat/types";
|
||||||
|
|
||||||
export type SliceTimelineItem =
|
import type { WorkspaceTimelineItem } from "./types";
|
||||||
| {
|
|
||||||
readonly kind: "message";
|
|
||||||
readonly message: FlueConversationMessage;
|
|
||||||
}
|
|
||||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
|
||||||
|
|
||||||
export const isSliceOneVisibleMessage = (
|
export const isVisibleConversationMessage = (
|
||||||
message: FlueConversationMessage
|
message: ConversationMessage
|
||||||
): boolean =>
|
): boolean =>
|
||||||
message.role === "user" ||
|
message.role === "user" ||
|
||||||
!hasToolActivity(message) ||
|
|
||||||
getMessageText(message).length > 0 ||
|
getMessageText(message).length > 0 ||
|
||||||
getReasoningText(message).length > 0;
|
getReasoningText(message).length > 0;
|
||||||
|
|
||||||
const targetIndexForNotice = (
|
const targetIndexForNotice = (
|
||||||
messages: readonly FlueConversationMessage[],
|
messages: readonly ConversationMessage[],
|
||||||
notice: WorkNotice
|
notice: WorkNotice
|
||||||
): number => {
|
): number => {
|
||||||
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
|
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
|
||||||
@@ -40,17 +33,15 @@ const targetIndexForNotice = (
|
|||||||
const sourceIndex = Math.max(...sourceIndexes);
|
const sourceIndex = Math.max(...sourceIndexes);
|
||||||
const responseOffset = messages
|
const responseOffset = messages
|
||||||
.slice(sourceIndex + 1)
|
.slice(sourceIndex + 1)
|
||||||
.findIndex(
|
.findIndex((message) => message.role === "assistant");
|
||||||
(message) => message.role === "assistant" && !hasToolActivity(message)
|
|
||||||
);
|
|
||||||
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSliceOneTimeline = (
|
export const buildWorkspaceTimeline = (
|
||||||
allMessages: readonly FlueConversationMessage[],
|
allMessages: readonly ConversationMessage[],
|
||||||
notices: readonly WorkNotice[]
|
notices: readonly WorkNotice[]
|
||||||
): readonly SliceTimelineItem[] => {
|
): readonly WorkspaceTimelineItem[] => {
|
||||||
const messages = allMessages.filter(isSliceOneVisibleMessage);
|
const messages = allMessages.filter(isVisibleConversationMessage);
|
||||||
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
|
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
|
||||||
for (const notice of notices) {
|
for (const notice of notices) {
|
||||||
const targetIndex = targetIndexForNotice(messages, notice);
|
const targetIndex = targetIndexForNotice(messages, notice);
|
||||||
@@ -59,7 +50,7 @@ export const buildSliceOneTimeline = (
|
|||||||
noticesByMessageIndex.set(targetIndex, atTarget);
|
noticesByMessageIndex.set(targetIndex, atTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline: SliceTimelineItem[] = [];
|
const timeline: WorkspaceTimelineItem[] = [];
|
||||||
for (const [index, message] of messages.entries()) {
|
for (const [index, message] of messages.entries()) {
|
||||||
timeline.push({ kind: "message", message });
|
timeline.push({ kind: "message", message });
|
||||||
for (const notice of noticesByMessageIndex.get(index) ?? []) {
|
for (const notice of noticesByMessageIndex.get(index) ?? []) {
|
||||||
@@ -75,7 +66,7 @@ export const buildSliceOneTimeline = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const findSourceMessageTarget = (
|
export const findSourceMessageTarget = (
|
||||||
messages: readonly FlueConversationMessage[],
|
messages: readonly ConversationMessage[],
|
||||||
sourceText: string
|
sourceText: string
|
||||||
): string | undefined =>
|
): string | undefined =>
|
||||||
messages.find(
|
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 { WebAuthProvider } from "@code/auth/web";
|
||||||
import { env } from "@code/env/web";
|
|
||||||
import { Toaster } from "@code/ui/components/sonner";
|
import { Toaster } from "@code/ui/components/sonner";
|
||||||
import { FlueProvider } from "@flue/react";
|
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { createFlueClient } from "@flue/sdk";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
Links,
|
Links,
|
||||||
@@ -13,11 +10,14 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
|
useNavigation,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import { ThemeProvider } from "./components/theme-provider";
|
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 = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
{ 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 }) => (
|
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -79,20 +51,42 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = () => (
|
const RouteProgress = () => {
|
||||||
<WebAuthProvider>
|
const navigation = useNavigation();
|
||||||
<AuthenticatedFlueProvider>
|
if (!navigation.location) {
|
||||||
<ThemeProvider
|
return null;
|
||||||
attribute="class"
|
}
|
||||||
defaultTheme="dark"
|
return (
|
||||||
forcedTheme="dark"
|
<output
|
||||||
disableTransitionOnChange
|
aria-label="Loading page"
|
||||||
storageKey="vite-ui-theme"
|
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
|
||||||
>
|
>
|
||||||
<Outlet />
|
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
|
||||||
<Toaster richColors />
|
</output>
|
||||||
</ThemeProvider>
|
);
|
||||||
</AuthenticatedFlueProvider>
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
forcedTheme="dark"
|
||||||
|
disableTransitionOnChange
|
||||||
|
storageKey="vite-ui-theme"
|
||||||
|
>
|
||||||
|
<RouteProgress />
|
||||||
|
<Outlet />
|
||||||
|
<Toaster richColors />
|
||||||
|
</ThemeProvider>
|
||||||
</WebAuthProvider>
|
</WebAuthProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,9 @@ import { index, layout, route } from "@react-router/dev/routes";
|
|||||||
import type { RouteConfig } from "@react-router/dev/routes";
|
import type { RouteConfig } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
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", [
|
layout("./routes/auth/layout.tsx", [
|
||||||
route("login", "./routes/auth/login/page.tsx"),
|
route("login", "./routes/auth/login/page.tsx"),
|
||||||
route("signup", "./routes/auth/signup/page.tsx"),
|
route("signup", "./routes/auth/signup/page.tsx"),
|
||||||
]),
|
]),
|
||||||
layout("./routes/app/layout.tsx", [
|
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.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"),
|
|
||||||
]),
|
|
||||||
] satisfies RouteConfig;
|
] 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 { Outlet } from "react-router";
|
||||||
import { Navigate, Outlet, useLocation } 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() {
|
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 />;
|
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 { Outlet } from "react-router";
|
||||||
import { Navigate, 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() {
|
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 (
|
return (
|
||||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LoginForm } from "@code/auth/web";
|
import { LoginForm } from "@code/auth/web";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
import type { Route } from "./+types/page";
|
||||||
|
|
||||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
|||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const navigate = useNavigate();
|
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,18 @@ import { defineConfig } from "vite-plus";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||||
server: {
|
|
||||||
allowedHosts: true,
|
|
||||||
},
|
|
||||||
plugins: [tailwindcss(), reactRouter()],
|
plugins: [tailwindcss(), reactRouter()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["convex", "react", "react-dom"],
|
dedupe: ["convex", "react", "react-dom"],
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
"/api/auth": {
|
||||||
|
changeOrigin: true,
|
||||||
|
target: "https://befitting-dalmatian-161.convex.site",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
###############################################################################
|
|
||||||
# Zopu Runtime Deployment — Environment Template
|
|
||||||
#
|
|
||||||
# Copy to .env and fill in real values. Never commit .env to the repository.
|
|
||||||
# This file documents every environment group required by the single-node
|
|
||||||
# execution plane. Lines marked REQUIRED must be set before first start.
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Convex (control plane) — REQUIRED
|
|
||||||
# Public URLs for the self-hosted Convex deployment.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CONVEX_URL=https://your-deployment.convex.cloud
|
|
||||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
|
||||||
SITE_URL=http://localhost:5173
|
|
||||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
|
||||||
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
|
|
||||||
VITE_FLUE_URL=http://localhost:3583
|
|
||||||
|
|
||||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
|
||||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
|
||||||
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
|
|
||||||
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
|
|
||||||
# All model calls route through this OpenAI-compatible endpoint.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AGENT_MODEL_PROVIDER=cheaptricks
|
|
||||||
AGENT_MODEL_NAME=glm-5.2
|
|
||||||
AGENT_MODEL_API=openai-completions
|
|
||||||
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
|
|
||||||
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
|
|
||||||
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).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#RIVET_ENDPOINT=http://localhost:6420
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. 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
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Daemon identity
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DAEMON_ID=zopu-dedicated
|
|
||||||
DAEMON_NAME=Zopu-Dedicated-Server
|
|
||||||
DAEMON_VERSION=0.0.0
|
|
||||||
DAEMON_HEARTBEAT_MS=15000
|
|
||||||
DAEMON_COMMAND_LEASE_MS=60000
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Docker sandbox
|
|
||||||
# The zopu service user is added to the docker group during bootstrap.
|
|
||||||
# Orb sandboxes will use Docker for full-system isolation, but the Orb
|
|
||||||
# lane contract has not landed yet. Docker access is provisioned now so
|
|
||||||
# the boundary is ready; no Docker-backed sandbox code is wired today.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# No env vars required; Docker socket access is via group membership.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Service authentication / secrets
|
|
||||||
# These tokens authenticate inter-service calls. Generate strong randoms.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
|
|
||||||
#AUTH_SECRET=replace-with-64-char-hex
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Private networking (Tailscale) — OPTIONAL
|
|
||||||
# When Tailscale is available, set the hostname for private DNS.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#TAILSCALE_HOSTNAME=zopu-runtime
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# Zopu Single-Node Runtime Deployment
|
|
||||||
|
|
||||||
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Debian Dedicated Server │
|
|
||||||
│ ~12 CPU cores · ~40 GB RAM · single-node │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
|
|
||||||
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
|
|
||||||
│ │ :13100 │ │ server.mjs │ │ backend │ │
|
|
||||||
│ │ │ │ :3583 │ │ :3210/:3211 │ │
|
|
||||||
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌──────▼───────┐ │
|
|
||||||
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ systemd timers: health-check (60s), docker-cleanup (daily) │
|
|
||||||
│ cron: disk-monitor (daily 06:00) │
|
|
||||||
│ ufw: deny-incoming, SSH + tailscale0 │
|
|
||||||
│ Tailscale: optional private overlay │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Single-node RivetKit topology
|
|
||||||
|
|
||||||
The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
|
|
||||||
|
|
||||||
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()` → `runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
|
|
||||||
|
|
||||||
**No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
|
|
||||||
|
|
||||||
### Docker / Orb boundary
|
|
||||||
|
|
||||||
Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
```
|
|
||||||
deploy/zopu-runtime/
|
|
||||||
├── bootstrap.sh # One-shot Debian installer
|
|
||||||
├── .env.template # Environment template (all groups documented)
|
|
||||||
├── README.md # This file (runbook)
|
|
||||||
├── systemd/
|
|
||||||
│ ├── zopu-daemon.service # Daemon systemd unit
|
|
||||||
│ ├── zopu-agent.service # Agent systemd unit
|
|
||||||
│ ├── zopu-web.service # React Router web systemd unit
|
|
||||||
│ ├── zopu-health.service # Health check oneshot
|
|
||||||
│ ├── zopu-health.timer # Health check every 60s
|
|
||||||
│ ├── zopu-docker-cleanup.service
|
|
||||||
│ └── zopu-docker-cleanup.timer
|
|
||||||
├── scripts/
|
|
||||||
│ ├── health-check.sh # TCP/process health probes
|
|
||||||
│ ├── update.sh # Update to branch or commit
|
|
||||||
│ ├── rollback.sh # Roll back to previous commit
|
|
||||||
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
|
|
||||||
│ └── disk-monitor.sh # Disk usage alerting
|
|
||||||
└── caddy/
|
|
||||||
└── Caddyfile # Optional reverse proxy config (documentation)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fresh install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. SSH into the fresh Debian 12 server as root.
|
|
||||||
|
|
||||||
# 2. Set environment overrides (optional):
|
|
||||||
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
|
|
||||||
export ZOPU_REPO_BRANCH="dogfood/v0"
|
|
||||||
# export TAILSCALE_AUTHKEY="tskey-..."
|
|
||||||
# export TAILSCALE_HOSTNAME="zopu-runtime"
|
|
||||||
|
|
||||||
# 3. Run the bootstrap script:
|
|
||||||
bash bootstrap.sh
|
|
||||||
|
|
||||||
# 4. Edit .env with real values:
|
|
||||||
nano /opt/zopu/.env
|
|
||||||
|
|
||||||
# 5. Start services:
|
|
||||||
systemctl start zopu-web zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl start zopu-agent
|
|
||||||
|
|
||||||
# 6. Enable timers:
|
|
||||||
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
|
|
||||||
|
|
||||||
# 7. Verify:
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start / stop / restart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start all services
|
|
||||||
systemctl start zopu-web zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
systemctl stop zopu-agent zopu-daemon zopu-web
|
|
||||||
|
|
||||||
# Restart (daemon first — it owns the RivetKit engine)
|
|
||||||
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
|
|
||||||
|
|
||||||
# Enable on boot
|
|
||||||
systemctl enable zopu-web zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Disable on boot
|
|
||||||
systemctl disable zopu-web zopu-daemon zopu-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Log inspection
|
|
||||||
|
|
||||||
All service logs go to journald with `SyslogIdentifier` tags.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Daemon logs (live follow)
|
|
||||||
journalctl -u zopu-daemon -f
|
|
||||||
|
|
||||||
# Agent logs (live follow)
|
|
||||||
journalctl -u zopu-agent -f
|
|
||||||
|
|
||||||
# Last 100 lines of daemon
|
|
||||||
journalctl -u zopu-daemon -n 100
|
|
||||||
|
|
||||||
# Logs since boot
|
|
||||||
journalctl -u zopu-daemon -b
|
|
||||||
|
|
||||||
# Health check timer logs
|
|
||||||
journalctl -u zopu-health.service -n 50
|
|
||||||
|
|
||||||
# Docker cleanup logs
|
|
||||||
journalctl -u zopu-docker-cleanup.service -n 50
|
|
||||||
|
|
||||||
# Disk monitor logs (cron → file)
|
|
||||||
tail -100 /var/log/zopu/disk-monitor.log
|
|
||||||
|
|
||||||
# All Zopu syslog identifiers
|
|
||||||
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual health check (prints all probes)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
|
|
||||||
# Quiet mode (exit code only)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
||||||
|
|
||||||
# Check systemd timer is running
|
|
||||||
systemctl status zopu-health.timer
|
|
||||||
systemctl list-timers zopu-health.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
The health check probes:
|
|
||||||
|
|
||||||
1. `zopu-daemon` systemd unit is active
|
|
||||||
2. `zopu-agent` systemd unit is active
|
|
||||||
3. RivetKit engine port (default 6420) accepts TCP connections
|
|
||||||
4. Flue agent port (default 3583) accepts TCP connections
|
|
||||||
5. Docker daemon responds to `docker info`
|
|
||||||
|
|
||||||
No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
|
|
||||||
|
|
||||||
## Update to commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update to latest of dogfood/v0 (default)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
|
|
||||||
|
|
||||||
# Update to a specific branch
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
|
|
||||||
|
|
||||||
# Update to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
The update script:
|
|
||||||
|
|
||||||
1. Records current HEAD to `.last-deployed-sha`
|
|
||||||
2. Fetches, resolves branch-or-commit, checks out
|
|
||||||
3. `bun install`, builds daemon and agent
|
|
||||||
4. Restarts daemon, waits, restarts agent
|
|
||||||
5. Runs health check; reports failure and rollback instructions
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Roll back to the previously deployed commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
|
|
||||||
|
|
||||||
# Roll back to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
|
|
||||||
|
|
||||||
## Docker cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual cleanup
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
||||||
|
|
||||||
# Check Docker disk usage
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
# Timer runs daily; check its schedule
|
|
||||||
systemctl list-timers zopu-docker-cleanup.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Cleanup prunes:
|
|
||||||
|
|
||||||
- Stopped containers older than 24 hours
|
|
||||||
- Dangling (untagged) images
|
|
||||||
- Unused networks
|
|
||||||
|
|
||||||
Named volumes and running containers are never removed.
|
|
||||||
|
|
||||||
## Disk-space monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual check
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
|
|
||||||
|
|
||||||
# Custom threshold (90%)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
|
|
||||||
```
|
|
||||||
|
|
||||||
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
|
|
||||||
|
|
||||||
## Firewall and private networking
|
|
||||||
|
|
||||||
The firewall (`ufw`) is deny-by-default:
|
|
||||||
|
|
||||||
- SSH (port 22) is allowed on all interfaces
|
|
||||||
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
|
|
||||||
- All other incoming traffic is denied
|
|
||||||
|
|
||||||
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
|
|
||||||
|
|
||||||
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
|
|
||||||
2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
|
|
||||||
```bash
|
|
||||||
ufw allow in on eth1 # or your private interface name
|
|
||||||
```
|
|
||||||
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
|
|
||||||
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
|
|
||||||
|
|
||||||
## Environment groups
|
|
||||||
|
|
||||||
See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
|
|
||||||
|
|
||||||
| Group | Variables |
|
|
||||||
| --- | --- |
|
|
||||||
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
|
|
||||||
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
|
|
||||||
| Model gateway | `AGENT_MODEL_*` |
|
|
||||||
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
|
|
||||||
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
|
|
||||||
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
|
|
||||||
| Docker sandbox | group membership (no env vars) |
|
|
||||||
| Service auth | `AUTH_SECRET` (if needed) |
|
|
||||||
|
|
||||||
## Public single-node routes
|
|
||||||
|
|
||||||
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
|
|
||||||
|
|
||||||
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
|
|
||||||
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
|
|
||||||
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
|
|
||||||
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
|
|
||||||
|
|
||||||
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose \
|
|
||||||
--env-file /opt/zopu/.env \
|
|
||||||
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
|
|
||||||
exec backend ./generate_admin_key.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## What is NOT deployed
|
|
||||||
|
|
||||||
- **Kubernetes**: no container orchestration.
|
|
||||||
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
|
|
||||||
- **Multi-node coordination**: single-node only.
|
|
||||||
- **Public administration endpoints**: no admin HTTP surface.
|
|
||||||
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
|
|
||||||
- **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
|
|
||||||
|
|
||||||
1. Services run under systemd with `Restart=always`.
|
|
||||||
2. Logs persist in journald.
|
|
||||||
3. Health checks run every 60 seconds via systemd timer.
|
|
||||||
4. Docker cleanup runs daily.
|
|
||||||
5. Disk usage is monitored daily.
|
|
||||||
6. Unattended-upgrades handles Debian security patches.
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
|
|
||||||
#
|
|
||||||
# Run as root on a fresh Debian 12 host:
|
|
||||||
#
|
|
||||||
# bash bootstrap.sh
|
|
||||||
#
|
|
||||||
# Environment overrides (set before running):
|
|
||||||
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
|
|
||||||
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
|
|
||||||
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
|
|
||||||
# ZOPU_SERVICE_USER — system user (default: zopu)
|
|
||||||
# TAILSCALE_AUTHKEY — if set, configure Tailscale
|
|
||||||
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
|
|
||||||
#
|
|
||||||
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
|
|
||||||
# web app, daemon, and agent, creates a non-root service user, installs systemd
|
|
||||||
# units, and configures firewall/Tailscale defaults.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
|
|
||||||
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
# runuser is part of util-linux (essential on Debian) and always available.
|
|
||||||
# sudo is NOT assumed on minimal Debian installs.
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pre-flight
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ "$EUID" -ne 0 ]]; then
|
|
||||||
err "This script must be run as root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f /etc/debian_version ]]; then
|
|
||||||
log "Detected Debian $(cat /etc/debian_version)"
|
|
||||||
else
|
|
||||||
warn "This script targets Debian 12. Other distributions may need manual adjustments."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. System packages
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Updating apt and installing base packages..."
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gnupg \
|
|
||||||
ufw \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
netcat-openbsd \
|
|
||||||
openssh-client \
|
|
||||||
unattended-upgrades \
|
|
||||||
rsyslog
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Docker Engine
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v docker &>/dev/null; then
|
|
||||||
log "Installing Docker Engine..."
|
|
||||||
install -m 0755 -d /etc/apt/keyrings
|
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
|
||||||
-o /etc/apt/keyrings/docker.asc
|
|
||||||
chmod a+r /etc/apt/keyrings/docker.asc
|
|
||||||
|
|
||||||
echo \
|
|
||||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
|
||||||
https://download.docker.com/linux/debian \
|
|
||||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
|
||||||
> /etc/apt/sources.list.d/docker.list
|
|
||||||
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
docker-ce \
|
|
||||||
docker-ce-cli \
|
|
||||||
containerd.io \
|
|
||||||
docker-buildx-plugin \
|
|
||||||
docker-compose-plugin
|
|
||||||
else
|
|
||||||
log "Docker Engine already installed: $(docker --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl enable --now docker
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Node.js 22 (required by the Flue Node target)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
|
|
||||||
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
|
|
||||||
log "Installing Node.js 22..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
|
|
||||||
bash /tmp/nodesource_setup.sh
|
|
||||||
apt-get install -y nodejs
|
|
||||||
else
|
|
||||||
log "Node.js already installed: $(node --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Bun
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v bun &>/dev/null; then
|
|
||||||
log "Installing Bun..."
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
else
|
|
||||||
log "Bun already installed: $(bun --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
log "Creating service user: $SERVICE_USER"
|
|
||||||
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
|
|
||||||
usermod -aG docker "$SERVICE_USER"
|
|
||||||
log "Added $SERVICE_USER to docker group"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Clone or update repository
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
log "Repository exists at $INSTALL_DIR, fetching latest..."
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
git fetch origin
|
|
||||||
git checkout "$REPO_BRANCH"
|
|
||||||
git reset --hard "origin/$REPO_BRANCH"
|
|
||||||
else
|
|
||||||
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
|
|
||||||
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6b. Hand ownership of the checkout to the service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Install dependencies and build
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Environment file
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="$INSTALL_DIR/.env"
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
|
|
||||||
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
warn "Edit $ENV_FILE with real values before starting services."
|
|
||||||
else
|
|
||||||
log ".env already exists at $ENV_FILE"
|
|
||||||
# Ensure correct ownership and permissions on existing .env
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Persistent log directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LOG_DIR="/var/log/zopu"
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 10. Install systemd units (substitute placeholders)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing systemd units..."
|
|
||||||
for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
|
|
||||||
zopu-health.timer zopu-health.service \
|
|
||||||
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
|
|
||||||
SRC="$DEPLOY_DIR/systemd/$unit"
|
|
||||||
DST="/etc/systemd/system/$unit"
|
|
||||||
if [[ -f "$SRC" ]]; then
|
|
||||||
sed \
|
|
||||||
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
|
|
||||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
|
||||||
"$SRC" > "$DST"
|
|
||||||
log " installed $unit"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Configuring firewall..."
|
|
||||||
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
|
|
||||||
ufw allow 22/tcp
|
|
||||||
ufw default deny incoming
|
|
||||||
ufw default allow outgoing
|
|
||||||
|
|
||||||
# Allow all traffic on the Tailscale interface (if present)
|
|
||||||
# This lets the agent and engine ports be reached over the private overlay.
|
|
||||||
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
|
|
||||||
|
|
||||||
ufw --force enable
|
|
||||||
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
|
|
||||||
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
|
|
||||||
warn "Reachability is via Tailscale (tailscale0) only."
|
|
||||||
else
|
|
||||||
log "Firewall already active. Ensuring tailscale0 rule..."
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 12. Tailscale (optional)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
|
|
||||||
log "Installing and configuring Tailscale..."
|
|
||||||
if ! command -v tailscaled &>/dev/null; then
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
fi
|
|
||||||
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
|
|
||||||
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
|
|
||||||
--accept-routes
|
|
||||||
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
|
|
||||||
|
|
||||||
# Re-apply the tailscale0 firewall rule now that the interface exists
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
else
|
|
||||||
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
|
|
||||||
warn "Without Tailscale, services are reachable only via localhost."
|
|
||||||
warn "To use a private network interface, add an explicit UFW rule:"
|
|
||||||
warn " ufw allow in on <interface>"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 13. Disk-space monitoring cron
|
|
||||||
# /etc/cron.d format REQUIRES a username field.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing disk-space monitor (daily at 06:00)..."
|
|
||||||
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
|
|
||||||
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
|
|
||||||
chmod 644 /etc/cron.d/zopu-disk-monitor
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Done
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Bootstrap complete."
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. Edit $ENV_FILE with real values"
|
|
||||||
echo " 2. Start services:"
|
|
||||||
echo " systemctl start zopu-web zopu-daemon zopu-agent"
|
|
||||||
echo " 3. Enable health monitoring:"
|
|
||||||
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
|
|
||||||
echo " 4. Verify health:"
|
|
||||||
echo " $DEPLOY_DIR/scripts/health-check.sh"
|
|
||||||
echo ""
|
|
||||||
warn "Services are NOT started automatically. Edit .env first."
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
zopu.cheaptricks.puter.wtf {
|
|
||||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
|
||||||
encode zstd gzip
|
|
||||||
|
|
||||||
handle_path /api/* {
|
|
||||||
reverse_proxy 127.0.0.1:3585
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy 127.0.0.1:5173
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
admin 127.0.0.1:2019
|
|
||||||
auto_https off
|
|
||||||
http_port 8080
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:13100
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-api.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3210
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-site.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3211
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-agent.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3583
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
|
|
||||||
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
- hostname: zopu.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-api.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-site.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-agent.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- service: http_status:404
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
image: ghcr.io/get-convex/convex-backend:latest
|
|
||||||
container_name: zopu-convex
|
|
||||||
restart: unless-stopped
|
|
||||||
stop_grace_period: 10s
|
|
||||||
stop_signal: SIGINT
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3210:3210"
|
|
||||||
- "127.0.0.1:3211:3211"
|
|
||||||
volumes:
|
|
||||||
- zopu-convex-data:/convex/data
|
|
||||||
environment:
|
|
||||||
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
|
|
||||||
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
|
|
||||||
DISABLE_BEACON: "true"
|
|
||||||
DISABLE_METRICS_ENDPOINT: "true"
|
|
||||||
DOCUMENT_RETENTION_DELAY: "172800"
|
|
||||||
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
|
|
||||||
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
|
|
||||||
REDACT_LOGS_TO_CLIENT: "true"
|
|
||||||
RUST_LOG: ${CONVEX_RUST_LOG:-info}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
zopu-convex-data:
|
|
||||||
name: zopu-convex-data
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# disk-monitor.sh — Check disk usage and alert when above threshold.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# disk-monitor.sh # print usage, warn at 80%
|
|
||||||
# disk-monitor.sh --warn-percent 90 # custom threshold
|
|
||||||
#
|
|
||||||
# Requires GNU coreutils df (standard on Debian). Uses --output for
|
|
||||||
# deterministic column ordering regardless of locale.
|
|
||||||
#
|
|
||||||
# Exit code 0 if under threshold, 1 if at or above.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
WARN_PERCENT=80
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--warn-percent)
|
|
||||||
WARN_PERCENT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown argument: $1" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
EXIT_CODE=0
|
|
||||||
|
|
||||||
# Partitions to check: install root and /var (Docker data-root is often here)
|
|
||||||
PARTITIONS="${PARTITIONS:-/ /var}"
|
|
||||||
|
|
||||||
for partition in $PARTITIONS; do
|
|
||||||
if [[ ! -d "$partition" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
|
|
||||||
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
|
|
||||||
|
|
||||||
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo " [skip] Could not read usage for ${partition}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
|
|
||||||
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
|
|
||||||
EXIT_CODE=1
|
|
||||||
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
|
|
||||||
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
|
|
||||||
else
|
|
||||||
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
exit "$EXIT_CODE"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
|
|
||||||
#
|
|
||||||
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
|
|
||||||
# commands with conservative scope.
|
|
||||||
#
|
|
||||||
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
|
|
||||||
# safely auto-pruned in a deployment that mixes stateful workloads. When the
|
|
||||||
# Orb lane lands with labeled resources, volume pruning can be scoped to
|
|
||||||
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
|
|
||||||
# manage volumes manually.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
# Remove stopped containers older than 24 hours
|
|
||||||
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
|
|
||||||
docker container prune -f --filter "until=24h"
|
|
||||||
|
|
||||||
# Remove dangling images (untagged intermediate layers only)
|
|
||||||
echo "[docker-cleanup] Pruning dangling images..."
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
# Remove unused networks
|
|
||||||
echo "[docker-cleanup] Pruning unused networks..."
|
|
||||||
docker network prune -f
|
|
||||||
|
|
||||||
# Volumes are intentionally NOT pruned. See header comment.
|
|
||||||
|
|
||||||
# Show remaining disk usage
|
|
||||||
echo "[docker-cleanup] Docker disk usage:"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
echo "[docker-cleanup] Done."
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# health-check.sh — Probe the Zopu execution plane.
|
|
||||||
#
|
|
||||||
# Checks (TCP/process-level; no assumed HTTP health routes):
|
|
||||||
# 1. zopu-daemon systemd unit is active
|
|
||||||
# 2. zopu-agent systemd unit is active
|
|
||||||
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
|
|
||||||
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
|
|
||||||
# 5. Docker daemon is reachable
|
|
||||||
#
|
|
||||||
# Flue does not expose a health endpoint by design. RivetKit's health route
|
|
||||||
# is internal to the registry runtime. We use TCP connection checks only.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# health-check.sh # print results
|
|
||||||
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
|
|
||||||
#
|
|
||||||
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
QUIET=false
|
|
||||||
[[ "${1:-}" == "--quiet" ]] && QUIET=true
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
|
|
||||||
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
|
|
||||||
|
|
||||||
FAILURES=0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Load environment
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
|
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
set -a
|
|
||||||
. "$ENV_FILE"
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
RIVET_PORT="6420"
|
|
||||||
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
|
|
||||||
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
|
|
||||||
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
|
|
||||||
fi
|
|
||||||
|
|
||||||
AGENT_PORT="${PORT:-3583}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
|
|
||||||
# Falls back to bash /dev/tcp if nc is unavailable.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
tcp_probe() {
|
|
||||||
local host="$1" port="$2"
|
|
||||||
if command -v nc &>/dev/null; then
|
|
||||||
nc -z -w 5 "$host" "$port" 2>/dev/null
|
|
||||||
else
|
|
||||||
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Daemon systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
|
|
||||||
pass "zopu-daemon service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-daemon service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Agent systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
|
|
||||||
pass "zopu-agent service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-agent service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. RivetKit engine TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$RIVET_PORT"; then
|
|
||||||
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Flue agent TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$AGENT_PORT"; then
|
|
||||||
pass "Flue agent port ${AGENT_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Docker daemon
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if docker info &>/dev/null; then
|
|
||||||
pass "Docker daemon is reachable"
|
|
||||||
else
|
|
||||||
fail "Docker daemon is not reachable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
$QUIET || echo ""
|
|
||||||
if [[ "$FAILURES" -eq 0 ]]; then
|
|
||||||
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# rollback.sh — Roll back to the previously deployed commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# rollback.sh # roll back to .last-deployed-sha
|
|
||||||
# rollback.sh <sha> # roll back to a specific commit
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Determine target
|
|
||||||
ROLLBACK_SHA="${1:-}"
|
|
||||||
if [[ -z "$ROLLBACK_SHA" ]]; then
|
|
||||||
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
if [[ ! -f "$LAST_SHA_FILE" ]]; then
|
|
||||||
err "No previous deployment recorded in ${LAST_SHA_FILE}."
|
|
||||||
err "Pass a commit SHA explicitly: rollback.sh <sha>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate commit exists
|
|
||||||
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
|
|
||||||
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current: ${CURRENT_SHA:0:12}"
|
|
||||||
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
|
|
||||||
|
|
||||||
# Save current state before rolling back (enables re-rollback)
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
|
|
||||||
|
|
||||||
# Checkout target
|
|
||||||
git checkout "$ROLLBACK_SHA"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
systemctl restart zopu-web
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Rollback complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after rollback!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
err " journalctl -u zopu-web -n 50"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# update.sh — Update the Zopu runtime to a specific branch or commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# update.sh # update to latest dogfood/v0
|
|
||||||
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
|
|
||||||
# update.sh <commit-sha> # checkout and build a specific commit
|
|
||||||
#
|
|
||||||
# The argument is treated as a branch name first; if no matching remote
|
|
||||||
# tracking branch exists it is treated as a commit SHA.
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
TARGET="${1:-dogfood/v0}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[update]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[update]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Record current commit for rollback
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current HEAD: ${CURRENT_SHA:0:12}"
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
|
|
||||||
# Fetch all remotes
|
|
||||||
log "Fetching from origin..."
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# Resolve target: branch first, then commit
|
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
|
|
||||||
log "Target is a branch: ${TARGET}"
|
|
||||||
git checkout -B "$TARGET" "origin/${TARGET}"
|
|
||||||
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
|
|
||||||
log "Target is a commit: ${TARGET:0:12}"
|
|
||||||
git checkout "$TARGET"
|
|
||||||
else
|
|
||||||
err "'${TARGET}' is not a known branch or valid commit."
|
|
||||||
err "Available branches: $(git branch -r | tr '\n' ' ')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NEW_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Now at: ${NEW_SHA:0:12}"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install and build
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
systemctl restart zopu-web
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Update complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after update!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
err " journalctl -u zopu-web -n 50"
|
|
||||||
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Update complete. Verify manually."
|
|
||||||
fi
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Agent Service (Flue Node server)
|
|
||||||
After=network-online.target zopu-daemon.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__/packages/agents
|
|
||||||
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=PORT=3583
|
|
||||||
|
|
||||||
# Flue's Node target requires Node built-ins such as node:sqlite.
|
|
||||||
# Listens on the service-pinned port 3583.
|
|
||||||
ExecStart=/usr/bin/node dist/server.mjs
|
|
||||||
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-agent
|
|
||||||
|
|
||||||
# Resource limits
|
|
||||||
MemoryMax=8G
|
|
||||||
CPUWeight=80
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Docker socket access for Orb sandbox runtime (lives in agent process)
|
|
||||||
SupplementaryGroups=docker
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Daemon (Bun/Effect + AgentOS/RivetKit)
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__
|
|
||||||
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=HOME=/var/lib/zopu
|
|
||||||
StateDirectory=zopu
|
|
||||||
|
|
||||||
# RivetKit in-process engine: envoy mode (serverful).
|
|
||||||
# Run the Bun source entrypoint so AgentOS software assets retain their real
|
|
||||||
# node_modules paths; Bun compiled executables do not embed .aospkg files.
|
|
||||||
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
|
|
||||||
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-daemon
|
|
||||||
|
|
||||||
# Resource limits (12-core, 40 GB host)
|
|
||||||
MemoryMax=8G
|
|
||||||
CPUWeight=100
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Docker socket access (for future Orb sandboxes; group membership required)
|
|
||||||
SupplementaryGroups=docker
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Docker Cleanup
|
|
||||||
After=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Docker Cleanup (daily)
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=daily
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Health Check
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
# health-check.sh reads ENV_FILE to find the .env to source. Set it to the
|
|
||||||
# install path so custom install dirs work correctly.
|
|
||||||
Environment=ENV_FILE=__INSTALL_DIR__/.env
|
|
||||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Health Check (every 60s)
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=30
|
|
||||||
OnUnitActiveSec=60
|
|
||||||
AccuracySec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Web
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__/apps/web
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=HOST=127.0.0.1
|
|
||||||
Environment=PORT=13100
|
|
||||||
ExecStart=/usr/local/bin/bun run start
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-web
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
269
docs/TECH.md
269
docs/TECH.md
@@ -10,62 +10,59 @@
|
|||||||
## 1. System topology
|
## 1. System topology
|
||||||
|
|
||||||
```text
|
```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
|
Private agent backend
|
||||||
├── ProjectActor
|
├── FLUE product agents and typed tools
|
||||||
├── WorkActor
|
├── AgentOS execution environments
|
||||||
├── AttemptActor
|
├── Codex implementation harness
|
||||||
├── VerificationActor (later split)
|
└── canonical events/results returned to Convex
|
||||||
├── IntegrationActor (later)
|
│ optional attached full sandbox
|
||||||
└── ResultActor (later)
|
|
||||||
│
|
|
||||||
▼
|
▼
|
||||||
FLUE orchestration/application agents
|
Cube/E2B-compatible runtime (later)
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### Ownership
|
||||||
|
|
||||||
| Layer | Owns |
|
| Layer | Owns |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Work OS | durable intent, lifecycle, evidence, policy |
|
| Convex | authentication, normalized product records, command admission, reactive reads |
|
||||||
| Rivet actors | serialized ownership, recovery, timers, leases |
|
| Convex Workflow | durable sequencing, retries, cancellation, scheduling, and completion callbacks |
|
||||||
| FLUE | programmable orchestration and domain-specific agents |
|
| 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 |
|
| Harness | bounded coding/tool loop |
|
||||||
| Sandbox/runtime | filesystem, processes, network, isolation |
|
| Sandbox/runtime | filesystem, processes, network, isolation |
|
||||||
| Git | source revision history |
|
| Git | source revision history |
|
||||||
| Artifact store | durable outputs/evidence |
|
| Artifact store | durable outputs/evidence |
|
||||||
| External systems | collaboration, delivery, monitoring |
|
| 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
|
## 2. Domain boundaries
|
||||||
|
|
||||||
@@ -104,93 +101,93 @@ Avoid package proliferation in v0; logical boundaries may begin as folders.
|
|||||||
Minimal durable model:
|
Minimal durable model:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Id = string
|
type Id = string;
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: Id
|
id: Id;
|
||||||
projectId: Id
|
projectId: Id;
|
||||||
content: string
|
content: string;
|
||||||
createdAt: number
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Signal {
|
interface Signal {
|
||||||
id: Id
|
id: Id;
|
||||||
projectId: Id
|
projectId: Id;
|
||||||
sourceType: string
|
sourceType: string;
|
||||||
sourceId: string
|
sourceId: string;
|
||||||
sourcePayloadRef?: string
|
sourcePayloadRef?: string;
|
||||||
summary: string
|
summary: string;
|
||||||
fingerprint: string
|
fingerprint: string;
|
||||||
status: "candidate" | "accepted" | "dismissed"
|
status: "candidate" | "accepted" | "dismissed";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Work {
|
interface Work {
|
||||||
id: Id
|
id: Id;
|
||||||
projectId: Id
|
projectId: Id;
|
||||||
title: string
|
title: string;
|
||||||
objective: string
|
objective: string;
|
||||||
risk: "low" | "medium" | "high"
|
risk: "low" | "medium" | "high";
|
||||||
status: WorkStatus
|
status: WorkStatus;
|
||||||
definitionVersion?: number
|
definitionVersion?: number;
|
||||||
designVersion?: number
|
designVersion?: number;
|
||||||
createdAt: number
|
createdAt: number;
|
||||||
updatedAt: number
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Step {
|
interface Step {
|
||||||
id: Id
|
id: Id;
|
||||||
workId: Id
|
workId: Id;
|
||||||
sliceId?: Id
|
sliceId?: Id;
|
||||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
|
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe";
|
||||||
objective: string
|
objective: string;
|
||||||
dependsOn: readonly Id[]
|
dependsOn: readonly Id[];
|
||||||
status: StepStatus
|
status: StepStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Run {
|
interface Run {
|
||||||
id: Id
|
id: Id;
|
||||||
workId: Id
|
workId: Id;
|
||||||
stepId: Id
|
stepId: Id;
|
||||||
kitVersion: string
|
kitVersion: string;
|
||||||
status: RunStatus
|
status: RunStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Attempt {
|
interface Attempt {
|
||||||
id: Id
|
id: Id;
|
||||||
runId: Id
|
runId: Id;
|
||||||
number: number
|
number: number;
|
||||||
harness: string
|
harness: string;
|
||||||
runtime: string
|
runtime: string;
|
||||||
sourceRevision: string
|
sourceRevision: string;
|
||||||
status: AttemptStatus
|
status: AttemptStatus;
|
||||||
startedAt?: number
|
startedAt?: number;
|
||||||
endedAt?: number
|
endedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Artifact {
|
interface Artifact {
|
||||||
id: Id
|
id: Id;
|
||||||
workId: Id
|
workId: Id;
|
||||||
stepId?: Id
|
stepId?: Id;
|
||||||
runId?: Id
|
runId?: Id;
|
||||||
attemptId?: Id
|
attemptId?: Id;
|
||||||
type: string
|
type: string;
|
||||||
uri?: string
|
uri?: string;
|
||||||
contentHash?: string
|
contentHash?: string;
|
||||||
sourceRevision?: string
|
sourceRevision?: string;
|
||||||
environmentId?: string
|
environmentId?: string;
|
||||||
metadata: unknown
|
metadata: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: Id
|
id: Id;
|
||||||
workId: Id
|
workId: Id;
|
||||||
stepId?: Id
|
stepId?: Id;
|
||||||
attemptId?: Id
|
attemptId?: Id;
|
||||||
prompt: string
|
prompt: string;
|
||||||
recommendation?: string
|
recommendation?: string;
|
||||||
alternatives: readonly string[]
|
alternatives: readonly string[];
|
||||||
status: "open" | "answered" | "withdrawn"
|
status: "open" | "answered" | "withdrawn";
|
||||||
answer?: string
|
answer?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -335,35 +332,49 @@ Keep domain/application code provider-neutral.
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface HarnessRuntime {
|
interface HarnessRuntime {
|
||||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
|
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>;
|
||||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
|
prompt(id: string, content: string): Effect.Effect<void, HarnessError>;
|
||||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
|
events(id: string): Stream.Stream<HarnessEvent, HarnessError>;
|
||||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
|
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>;
|
||||||
abort(id: string): Effect.Effect<void, HarnessError>
|
abort(id: string): Effect.Effect<void, HarnessError>;
|
||||||
close(id: string): Effect.Effect<void, HarnessError>
|
close(id: string): Effect.Effect<void, HarnessError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SandboxRuntime {
|
interface SandboxRuntime {
|
||||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
|
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>;
|
||||||
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
|
exec(
|
||||||
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
|
lease: SandboxLease,
|
||||||
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
|
cmd: Command
|
||||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
): Effect.Effect<CommandResult, SandboxError>;
|
||||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
|
readFile(
|
||||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
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 {
|
interface SourceControl {
|
||||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
|
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>;
|
||||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
|
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>;
|
||||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
|
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>;
|
||||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
|
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>;
|
||||||
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
|
createPullRequest(
|
||||||
|
input: PullRequestInput
|
||||||
|
): Effect.Effect<PullRequestArtifact, GitError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerificationRuntime {
|
interface VerificationRuntime {
|
||||||
execute(plan: VerificationPlan, env: EnvironmentRef):
|
execute(
|
||||||
Effect.Effect<VerificationResult, VerificationError>
|
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
|
## 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
|
### CubeSandbox
|
||||||
|
|
||||||
Best for full Linux execution:
|
Best for full Linux execution:
|
||||||
@@ -441,7 +456,7 @@ Best for:
|
|||||||
- actor-adjacent orchestration;
|
- actor-adjacent orchestration;
|
||||||
- context/files/networking that fit runtime limits.
|
- 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
|
### Persistent project machine
|
||||||
|
|
||||||
|
|||||||
78
docs/auth-proxy.md
Normal file
78
docs/auth-proxy.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Auth Proxy — Production Ingress Requirement
|
||||||
|
|
||||||
|
The application uses **same-origin authentication**: the browser and React Router SSR both hit
|
||||||
|
`/api/auth/*` on the public application domain. This keeps cookies first-party, avoids
|
||||||
|
cross-origin credentials, and gives development and production the same API surface.
|
||||||
|
|
||||||
|
## Required production route
|
||||||
|
|
||||||
|
At the public application domain, route:
|
||||||
|
|
||||||
|
| Prefix | Target |
|
||||||
|
|---|---|
|
||||||
|
| `/api/auth/*` | Convex HTTP site |
|
||||||
|
| `/*` | React Router frontend |
|
||||||
|
|
||||||
|
### Caddy
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
zopu.example.com {
|
||||||
|
handle /api/auth/* {
|
||||||
|
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
||||||
|
header_up Host befitting-dalmatian-161.convex.site
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dokploy / Traefik
|
||||||
|
|
||||||
|
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
|
||||||
|
site URL. Ensure it:
|
||||||
|
|
||||||
|
- Preserves the original browser `Cookie` header
|
||||||
|
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
|
||||||
|
- Preserves the original method and body
|
||||||
|
- Forwards `X-Forwarded-Host` and `X-Forwarded-Proto`
|
||||||
|
- Passes the full path unchanged (e.g. `/api/auth/convex/token`)
|
||||||
|
- Does not cache auth responses
|
||||||
|
- Allows OAuth callback routes under the same prefix
|
||||||
|
|
||||||
|
## Convex environment
|
||||||
|
|
||||||
|
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
|
||||||
|
site URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
npx convex env set SITE_URL 'https://zopu.example.com'
|
||||||
|
|
||||||
|
# Local
|
||||||
|
npx convex env set SITE_URL 'http://100.101.157.28:5173'
|
||||||
|
|
||||||
|
# Staging
|
||||||
|
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
|
||||||
|
```
|
||||||
|
|
||||||
|
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
|
||||||
|
`CONVEX_SITE_URL` as the internal `baseURL` for route registration; that value is the HTTP
|
||||||
|
site URL (e.g. `https://befitting-dalmatian-161.convex.site`).
|
||||||
|
|
||||||
|
## Why same-origin
|
||||||
|
|
||||||
|
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
|
||||||
|
2. **SSR consistency.** The React Router server uses the same auth surface the browser
|
||||||
|
sees; no cross-host credential translation.
|
||||||
|
3. **No CORS complexity.** The browser talks only to its own origin.
|
||||||
|
4. **The reverse proxy handles the cross-host hop server-side.**
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Better Auth: Trusted Origins](https://github.com/better-auth/better-auth/blob/main/docs/content/docs/reference/security.mdx)
|
||||||
|
- `apps/web/vite.config.ts` — Vite dev proxy (`/api/auth` → Convex site)
|
||||||
|
- `packages/auth/src/web/auth-client.ts` — `window.location.origin`
|
||||||
|
- `apps/web/src/lib/auth.server.ts` — SSR token loader via request origin
|
||||||
@@ -211,16 +211,18 @@ Zopu implements one approved slice in an isolated repository environment and str
|
|||||||
Initial adapter choice:
|
Initial adapter choice:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SandboxRuntime = CubeSandboxLive
|
SandboxRuntime = AgentOsSandboxLive
|
||||||
HarnessRuntime = OmpHarnessLive (or one chosen harness)
|
HarnessRuntime = CodexHarnessLive
|
||||||
|
Durable orchestration = Convex Workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
prepare worktree
|
load the project's authenticated Git connection
|
||||||
→ create sandbox
|
→ start durable Convex workflow
|
||||||
→ clone/mount repo
|
→ create AgentOS execution environment
|
||||||
|
→ clone the single configured repo
|
||||||
→ inject context
|
→ inject context
|
||||||
→ run one slice
|
→ run one slice
|
||||||
→ normalize events
|
→ normalize events
|
||||||
@@ -230,14 +232,15 @@ prepare worktree
|
|||||||
|
|
||||||
Security:
|
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;
|
- isolated HOME/worktree;
|
||||||
- one mutating attempt per worktree;
|
- one mutating attempt per worktree;
|
||||||
- timeout/cancel cleanup.
|
- timeout/cancel cleanup.
|
||||||
|
|
||||||
### Frontend
|
### 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
|
### Acceptance
|
||||||
|
|
||||||
@@ -247,6 +250,8 @@ Current activity, changed files, artifact links, expandable raw logs.
|
|||||||
- provider failure becomes classified attempt outcome;
|
- provider failure becomes classified attempt outcome;
|
||||||
- exact base/candidate revision recorded.
|
- 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
|
## 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
|
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({
|
export default defineConfig({
|
||||||
extends: [core, react, remix],
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
116
package.json
116
package.json
@@ -1,53 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"private": true,
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vp run -r dev",
|
"dev": "vp run -r dev",
|
||||||
"build": "vp run -r build",
|
"build": "vp run -r build",
|
||||||
"check-types": "vp run -r check-types",
|
"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",
|
"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": "vp lint",
|
"lint": "oxlint --disable-nested-config",
|
||||||
"format": "vp fmt",
|
"format": "vp fmt",
|
||||||
"smoke:zopu": "bun scripts/zopu-smoke.ts",
|
|
||||||
"staged": "vp staged",
|
"staged": "vp staged",
|
||||||
"hooks:setup": "vp config",
|
"hooks:setup": "vp config",
|
||||||
"dev:web": "vp run --filter web dev",
|
"dev:web": "vp run --filter web dev",
|
||||||
@@ -57,16 +18,10 @@
|
|||||||
"dev:server": "vp run --filter @code/backend dev",
|
"dev:server": "vp run --filter @code/backend dev",
|
||||||
"dev:setup": "vp run --filter @code/backend dev:setup",
|
"dev:setup": "vp run --filter @code/backend dev:setup",
|
||||||
"build:agents": "vp run --filter @code/agents build",
|
"build:agents": "vp run --filter @code/agents build",
|
||||||
"docs:update": "bun run scripts/update-docs.ts",
|
"docs:update": "node scripts/update-docs.ts",
|
||||||
"subtree": "bun run scripts/subtree.ts",
|
"subtree": "node scripts/subtree.ts",
|
||||||
"fix": "ultracite fix",
|
"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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
@@ -74,20 +29,69 @@
|
|||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/sdk": "1.0.0-beta.9",
|
"@flue/sdk": "1.0.0-beta.9",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"convex-test": "catalog:",
|
"convex-test": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"oxfmt": "latest",
|
"oxfmt": "0.61.0",
|
||||||
"oxlint": "latest",
|
"oxlint": "1.76.0",
|
||||||
"rolldown": "1.1.4",
|
"rolldown": "1.1.4",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"ultracite": "7.9.3",
|
"ultracite": "7.9.3",
|
||||||
"vite-plus": "0.2.2",
|
"vite-plus": "0.2.2",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"packageManager": "pnpm@11.17.0",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
|
"workspaces": {
|
||||||
|
"packages": [
|
||||||
|
"apps/web",
|
||||||
|
"packages/agents",
|
||||||
|
"packages/auth",
|
||||||
|
"packages/backend",
|
||||||
|
"packages/config",
|
||||||
|
"packages/env",
|
||||||
|
"packages/primitives",
|
||||||
|
"packages/ui"
|
||||||
|
],
|
||||||
|
"catalog": {
|
||||||
|
"@rivet-dev/agentos": "0.2.14",
|
||||||
|
"@rivet-dev/agentos-core": "0.2.14",
|
||||||
|
"@effect/platform-bun": "4.0.0-beta.99",
|
||||||
|
"dotenv": "17.4.2",
|
||||||
|
"zod": "4.4.3",
|
||||||
|
"lucide-react": "1.27.0",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"sonner": "2.0.7",
|
||||||
|
"convex": "1.42.3",
|
||||||
|
"better-auth": "1.6.15",
|
||||||
|
"@convex-dev/better-auth": "0.12.5",
|
||||||
|
"@tanstack/react-form": "1.33.2",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"tailwind-merge": "3.6.0",
|
||||||
|
"@better-auth/expo": "1.6.15",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"@types/bun": "1.3.14",
|
||||||
|
"heroui-native": "1.0.6",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10",
|
||||||
|
"convex-test": "0.0.54",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/node": "22.20.1",
|
||||||
|
"hono": "4.12.32",
|
||||||
|
"valibot": "1.4.2",
|
||||||
|
"streamdown": "2.5.0",
|
||||||
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
"@tailwindcss/vite": "4.3.3"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.14"
|
"overrides": {
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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({
|
export default defineConfig({
|
||||||
target: 'node',
|
target: "node",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,34 +4,37 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"check-types": "tsc --noEmit",
|
||||||
"dev": "bun --env-file=../../.env flue dev",
|
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||||
"run": "bun --env-file=../../.env flue run",
|
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
|
||||||
"run:zopu": "bun --env-file=../../.env flue run zopu",
|
"runner": "node --env-file=../../.env src/runner.ts",
|
||||||
"run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev"
|
"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": {
|
"dependencies": {
|
||||||
"@agentos-software/opencode": "0.2.7",
|
"@agentos-software/git": "0.3.3",
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "latest",
|
||||||
"@rivet-dev/agentos-core": "catalog:",
|
"@rivet-dev/agentos": "0.2.14",
|
||||||
|
"@rivet-dev/agentos-core": "0.2.14",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"dockerode": "^5.0.1",
|
"hono": "catalog:",
|
||||||
"effect": "catalog:",
|
"rivetkit": "2.3.9",
|
||||||
"get-port": "^7.2.0",
|
"valibot": "catalog:"
|
||||||
"hono": "4.12.31",
|
|
||||||
"sandbox-agent": "0.4.2",
|
|
||||||
"valibot": "^1.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
"@flue/cli": "latest",
|
"@flue/cli": "latest",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/dockerode": "^4.0.1",
|
"@types/node": "catalog:",
|
||||||
"typescript": "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);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user