chore: checkpoint current worktree state
This commit is contained in:
@@ -0,0 +1,158 @@
|
|||||||
|
# Attachment-Based Review Context Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Use structured attachments as the source of truth for review context during agent creation.
|
||||||
|
|
||||||
|
This covers two related behaviors:
|
||||||
|
|
||||||
|
1. Provider-facing context for the agent prompt
|
||||||
|
2. Worktree checkout behavior when creating a worktree from a review item
|
||||||
|
|
||||||
|
The design should stay compatible with future forge support such as GitLab.
|
||||||
|
|
||||||
|
## Core Direction
|
||||||
|
|
||||||
|
- Do not parse the prompt to discover issue/PR intent.
|
||||||
|
- Do not introduce deeply nested transport objects for this flow.
|
||||||
|
- Make the attachment itself the discriminated union.
|
||||||
|
- Keep forge-specific checkout logic below the attachment layer.
|
||||||
|
- Let each agent provider translate attachments into its own prompt/input format.
|
||||||
|
|
||||||
|
## Initial Attachment Types
|
||||||
|
|
||||||
|
Two initial structured attachment types:
|
||||||
|
|
||||||
|
- `github_pr`
|
||||||
|
- `github_issue`
|
||||||
|
|
||||||
|
With separate MIME types:
|
||||||
|
|
||||||
|
- `application/github-pr`
|
||||||
|
- `application/github-issue`
|
||||||
|
|
||||||
|
Proposed wire shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AgentAttachment =
|
||||||
|
| {
|
||||||
|
type: "github_pr";
|
||||||
|
mimeType: "application/github-pr";
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
body?: string | null;
|
||||||
|
baseRefName?: string | null;
|
||||||
|
headRefName?: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "github_issue";
|
||||||
|
mimeType: "application/github-issue";
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
body?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
- Add new optional `attachments` fields; keep existing `images` fields.
|
||||||
|
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
|
||||||
|
- Malformed attachment entries should also be ignored safely.
|
||||||
|
- Old clients continue working because `attachments` is optional.
|
||||||
|
- New clients can send `attachments` without breaking older flows that still rely on `images`.
|
||||||
|
|
||||||
|
## Request-Level Changes
|
||||||
|
|
||||||
|
Add optional `attachments` to:
|
||||||
|
|
||||||
|
- `create_agent_request`
|
||||||
|
- `send_agent_message_request`
|
||||||
|
|
||||||
|
Existing `initialPrompt` remains plain user text.
|
||||||
|
|
||||||
|
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
|
||||||
|
|
||||||
|
## App Responsibilities
|
||||||
|
|
||||||
|
When the user selects a GitHub item:
|
||||||
|
|
||||||
|
- If it is a PR, create a `github_pr` attachment
|
||||||
|
- If it is an issue, create a `github_issue` attachment
|
||||||
|
- Include the attachment in the create-agent request
|
||||||
|
|
||||||
|
The UI can still render friendly labels and previews from the same data.
|
||||||
|
|
||||||
|
The app should stop treating PR/issue context as prompt-only metadata.
|
||||||
|
|
||||||
|
## Worktree Creation Behavior
|
||||||
|
|
||||||
|
During agent creation, if worktree creation is requested:
|
||||||
|
|
||||||
|
- Inspect normalized attachments
|
||||||
|
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
|
||||||
|
|
||||||
|
The important rule:
|
||||||
|
|
||||||
|
- attachment type identifies the review object
|
||||||
|
- server-side git logic decides how to check it out
|
||||||
|
|
||||||
|
This keeps the attachment contract simple while allowing fork-safe implementation details.
|
||||||
|
|
||||||
|
## Checkout Resolution
|
||||||
|
|
||||||
|
The attachment itself should not encode the full checkout strategy.
|
||||||
|
|
||||||
|
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
|
||||||
|
|
||||||
|
Examples of possible implementation strategies:
|
||||||
|
|
||||||
|
- `gh pr checkout`
|
||||||
|
- `git fetch origin refs/pull/<number>/head:<local-branch>`
|
||||||
|
- another GitHub-aware resolver if needed later
|
||||||
|
|
||||||
|
This is intentionally a server implementation detail, not part of the attachment schema.
|
||||||
|
|
||||||
|
## Provider Responsibilities
|
||||||
|
|
||||||
|
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Claude: render attachment into text blocks
|
||||||
|
- Codex: inject as blocks or text
|
||||||
|
- OpenCode: send as file/resource-like input where appropriate
|
||||||
|
- ACP: convert to resource/text forms as supported
|
||||||
|
|
||||||
|
The session layer should not hardcode one translation strategy for all providers.
|
||||||
|
|
||||||
|
## Suggested Implementation Order
|
||||||
|
|
||||||
|
1. Add tolerant attachment schema and normalization in shared/server message handling.
|
||||||
|
2. Thread `attachments` through app -> daemon client -> session layer.
|
||||||
|
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
|
||||||
|
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
|
||||||
|
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
|
||||||
|
6. Optionally migrate image transport into the same attachment mechanism later.
|
||||||
|
|
||||||
|
## Files Likely Involved
|
||||||
|
|
||||||
|
- `packages/server/src/shared/messages.ts`
|
||||||
|
- `packages/server/src/client/daemon-client.ts`
|
||||||
|
- `packages/server/src/server/session.ts`
|
||||||
|
- `packages/server/src/server/worktree-session.ts`
|
||||||
|
- `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/claude-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/acp-agent.ts`
|
||||||
|
- `packages/app/src/screens/new-workspace-screen.tsx`
|
||||||
|
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
|
||||||
|
- `packages/app/src/contexts/session-context.tsx`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The current image path can remain in place initially.
|
||||||
|
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
|
||||||
|
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
|
||||||
@@ -6,11 +6,10 @@ import {
|
|||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Dimensions, Platform, Text, View } from "react-native";
|
import { Dimensions, Platform, Text, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native";
|
import { ExternalLink } from "lucide-react-native";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
@@ -18,10 +17,7 @@ import { Portal } from "@gorhom/portal";
|
|||||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||||
|
|
||||||
interface Rect {
|
interface Rect {
|
||||||
@@ -108,8 +104,7 @@ function WorkspaceHoverCardDesktop({
|
|||||||
const triggerHoveredRef = useRef(false);
|
const triggerHoveredRef = useRef(false);
|
||||||
const contentHoveredRef = useRef(false);
|
const contentHoveredRef = useRef(false);
|
||||||
|
|
||||||
const hasScripts = workspace.scripts.length > 0;
|
const hasContent = prHint !== null || !!workspace.diffStat;
|
||||||
const hasContent = hasScripts || prHint !== null;
|
|
||||||
|
|
||||||
const clearGraceTimer = useCallback(() => {
|
const clearGraceTimer = useCallback(() => {
|
||||||
if (graceTimerRef.current) {
|
if (graceTimerRef.current) {
|
||||||
@@ -195,32 +190,6 @@ function WorkspaceHoverCardDesktop({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScriptHealthColor(input: {
|
|
||||||
health: SidebarWorkspaceEntry["scripts"][number]["health"];
|
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
|
||||||
}): string {
|
|
||||||
if (input.health === "healthy") {
|
|
||||||
return input.theme.colors.palette.blue[500];
|
|
||||||
}
|
|
||||||
if (input.health === "unhealthy") {
|
|
||||||
return input.theme.colors.palette.red[500];
|
|
||||||
}
|
|
||||||
return input.theme.colors.foregroundMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScriptHealthLabel(
|
|
||||||
health: SidebarWorkspaceEntry["scripts"][number]["health"],
|
|
||||||
): "Healthy" | "Unhealthy" | "Unknown" {
|
|
||||||
if (health === "healthy") {
|
|
||||||
return "Healthy";
|
|
||||||
}
|
|
||||||
if (health === "unhealthy") {
|
|
||||||
return "Unhealthy";
|
|
||||||
}
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function WorkspaceHoverCardContent({
|
function WorkspaceHoverCardContent({
|
||||||
workspace,
|
workspace,
|
||||||
prHint,
|
prHint,
|
||||||
@@ -235,39 +204,10 @@ function WorkspaceHoverCardContent({
|
|||||||
onContentLeave: () => void;
|
onContentLeave: () => void;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
|
||||||
const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null);
|
|
||||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const startScriptMutation = useMutation({
|
|
||||||
mutationFn: async (scriptName: string) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client not available");
|
|
||||||
}
|
|
||||||
const result = await client.startWorkspaceScript(workspace.workspaceId, scriptName);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.terminalId) {
|
|
||||||
navigateToPreparedWorkspaceTab({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
target: { kind: "terminal", terminalId: data.terminalId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error, scriptName) => {
|
|
||||||
toast.show(
|
|
||||||
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
|
|
||||||
{ variant: "error" },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Measure trigger — same pattern as tooltip.tsx
|
// Measure trigger — same pattern as tooltip.tsx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -345,167 +285,6 @@ function WorkspaceHoverCardContent({
|
|||||||
{prHint ? <PrBadge hint={prHint} /> : null}
|
{prHint ? <PrBadge hint={prHint} /> : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{workspace.scripts.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<View style={styles.separator} />
|
|
||||||
<View style={styles.sectionLabelRow}>
|
|
||||||
<Play size={12} color={theme.colors.foregroundMuted} fill="transparent" />
|
|
||||||
<Text style={styles.sectionLabel}>Scripts</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.sectionList} testID="hover-card-script-list">
|
|
||||||
{workspace.scripts.map((script) => {
|
|
||||||
const isRunning = script.lifecycle === "running";
|
|
||||||
const isService = (script.type ?? "service") === "service";
|
|
||||||
const isLinkable = isService && isRunning && !!script.url;
|
|
||||||
const exitCode = script.exitCode ?? null;
|
|
||||||
|
|
||||||
let dotColor: string;
|
|
||||||
if (isService) {
|
|
||||||
dotColor = isRunning
|
|
||||||
? getScriptHealthColor({ health: script.health, theme })
|
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
} else if (isRunning) {
|
|
||||||
dotColor = theme.colors.palette.blue[500];
|
|
||||||
} else if (exitCode === 0) {
|
|
||||||
dotColor = theme.colors.palette.green[500];
|
|
||||||
} else if (exitCode !== null) {
|
|
||||||
dotColor = theme.colors.palette.red[500];
|
|
||||||
} else {
|
|
||||||
dotColor = theme.colors.foregroundMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessibilityStatus: string;
|
|
||||||
if (isService) {
|
|
||||||
accessibilityStatus = isRunning ? getScriptHealthLabel(script.health) : "Stopped";
|
|
||||||
} else if (isRunning) {
|
|
||||||
accessibilityStatus = "Running";
|
|
||||||
} else if (exitCode === 0) {
|
|
||||||
accessibilityStatus = "Completed";
|
|
||||||
} else if (exitCode !== null) {
|
|
||||||
accessibilityStatus = `Failed (exit ${exitCode})`;
|
|
||||||
} else {
|
|
||||||
accessibilityStatus = "Stopped";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={script.hostname}
|
|
||||||
accessibilityRole={isLinkable ? "link" : undefined}
|
|
||||||
accessibilityLabel={`${script.scriptName} script — ${accessibilityStatus}`}
|
|
||||||
testID={`hover-card-script-${script.scriptName}`}
|
|
||||||
style={({ hovered }) => [
|
|
||||||
styles.listRow,
|
|
||||||
hovered && isLinkable && styles.listRowHovered,
|
|
||||||
]}
|
|
||||||
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
|
|
||||||
disabled={!isLinkable}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<>
|
|
||||||
{isService ? (
|
|
||||||
<Globe
|
|
||||||
size={14}
|
|
||||||
color={dotColor}
|
|
||||||
testID={`hover-card-script-health-${script.scriptName}`}
|
|
||||||
style={styles.scriptIcon}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SquareTerminal
|
|
||||||
size={14}
|
|
||||||
color={dotColor}
|
|
||||||
testID={`hover-card-script-health-${script.scriptName}`}
|
|
||||||
style={styles.scriptIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.listRowLabel,
|
|
||||||
{
|
|
||||||
color: isRunning
|
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{script.scriptName}
|
|
||||||
</Text>
|
|
||||||
{isService && isRunning && script.url ? (
|
|
||||||
<Text style={styles.listRowSecondary} numberOfLines={1}>
|
|
||||||
{script.url.replace(/^https?:\/\//, "")}
|
|
||||||
</Text>
|
|
||||||
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
|
|
||||||
<Text style={styles.listRowSecondary} numberOfLines={1}>
|
|
||||||
exit {exitCode}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={styles.listRowSpacer} />
|
|
||||||
)}
|
|
||||||
{isRunning ? (
|
|
||||||
isLinkable && hovered ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.externalLinkOverlay,
|
|
||||||
{
|
|
||||||
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ExternalLink
|
|
||||||
size={12}
|
|
||||||
color={theme.colors.foreground}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Run ${script.scriptName} script`}
|
|
||||||
testID={`hover-card-script-start-${script.scriptName}`}
|
|
||||||
hitSlop={4}
|
|
||||||
disabled={startScriptMutation.isPending}
|
|
||||||
onPress={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
startScriptMutation.mutate(script.scriptName);
|
|
||||||
}}
|
|
||||||
style={styles.startButton}
|
|
||||||
>
|
|
||||||
{({ hovered: actionHovered }) =>
|
|
||||||
startScriptMutation.isPending &&
|
|
||||||
startScriptMutation.variables === script.scriptName ? (
|
|
||||||
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play
|
|
||||||
size={10}
|
|
||||||
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
fill="transparent"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.startButtonLabel,
|
|
||||||
{
|
|
||||||
color: actionHovered
|
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Run
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.separator} />
|
<View style={styles.separator} />
|
||||||
@@ -619,30 +398,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
sectionLabelRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1.5],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingTop: theme.spacing[2],
|
|
||||||
paddingBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
sectionLabel: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
sectionList: {
|
|
||||||
paddingBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
listRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: 6,
|
|
||||||
minHeight: 28,
|
|
||||||
},
|
|
||||||
listRowHovered: {
|
listRowHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -656,27 +411,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
listRowLabel: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
lineHeight: 18,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
listRowSecondary: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: 18,
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
listRowSpacer: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
scriptIcon: {
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
checksSummaryRow: {
|
checksSummaryRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -706,13 +440,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
startButton: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 3,
|
|
||||||
},
|
|
||||||
startButtonLabel: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
? {
|
? {
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
|
WebkitAppRegion: "no-drag",
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ echo " Metro: http://localhost:${EXPO_PORT}"
|
|||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
|
|
||||||
# Launch Metro + Electron together, kill both on exit
|
# Launch Metro + Electron together, kill both on exit
|
||||||
"$ROOT_DIR/node_modules/.bin/concurrently" \
|
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||||
--kill-others \
|
--kill-others \
|
||||||
--names "metro,electron" \
|
--names "metro,electron" \
|
||||||
--prefix-colors "magenta,cyan" \
|
--prefix-colors "magenta,cyan" \
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
|
||||||
return {
|
return {
|
||||||
serverId: "",
|
serverId: "",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
@@ -218,7 +220,7 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
|||||||
home,
|
home,
|
||||||
version: null,
|
version: null,
|
||||||
desktopManaged: false,
|
desktopManaged: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +233,14 @@ function normalizeVersion(version: string | null): string | null {
|
|||||||
|
|
||||||
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
const current = await resolveStatus();
|
const current = await resolveStatus();
|
||||||
|
logDesktopDaemonLifecycle("initial status check before start", {
|
||||||
|
status: current.status,
|
||||||
|
pid: current.pid,
|
||||||
|
listen: current.listen,
|
||||||
|
serverId: current.serverId || null,
|
||||||
|
error: current.error,
|
||||||
|
desktopManaged: current.desktopManaged,
|
||||||
|
});
|
||||||
if (current.status === "running") {
|
if (current.status === "running") {
|
||||||
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
||||||
const daemonVersion = normalizeVersion(current.version);
|
const daemonVersion = normalizeVersion(current.version);
|
||||||
@@ -269,7 +279,16 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
||||||
detached: true,
|
detached: true,
|
||||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout!.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
child.stderr!.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||||
@@ -280,41 +299,54 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
|
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
// Wait for process to survive the grace period
|
type GraceResult =
|
||||||
const exitedEarly = await new Promise<boolean>((resolve) => {
|
| { exitedEarly: false }
|
||||||
|
| { exitedEarly: true; code: number | null; signal: string | null; error?: Error };
|
||||||
|
|
||||||
|
const result = await new Promise<GraceResult>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (value: boolean) => {
|
const finish = (value: GraceResult) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
resolve(value);
|
resolve(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
|
const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS);
|
||||||
|
|
||||||
child.once("error", () => {
|
child.once("error", (error) => {
|
||||||
logDesktopDaemonLifecycle("detached child emitted error during grace period", {
|
|
||||||
childPid: child.pid ?? null,
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
finish(true);
|
finish({ exitedEarly: true, code: null, signal: null, error });
|
||||||
});
|
});
|
||||||
child.once("exit", () => {
|
child.once("exit", (code, signal) => {
|
||||||
logDesktopDaemonLifecycle("detached child emitted exit during grace period", {
|
|
||||||
childPid: child.pid ?? null,
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
finish(true);
|
finish({ exitedEarly: true, code, signal });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||||
childPid: child.pid ?? null,
|
childPid: child.pid ?? null,
|
||||||
exitedEarly,
|
exitedEarly: result.exitedEarly,
|
||||||
|
...(result.exitedEarly
|
||||||
|
? {
|
||||||
|
exitCode: result.code,
|
||||||
|
signal: result.signal,
|
||||||
|
error: result.error?.message ?? null,
|
||||||
|
stdout: stdout.slice(0, 2000),
|
||||||
|
stderr: stderr.slice(0, 2000),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exitedEarly) {
|
if (result.exitedEarly) {
|
||||||
|
const reason = result.error
|
||||||
|
? result.error.message
|
||||||
|
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||||
|
const parts = [`Daemon failed to start: ${reason}`];
|
||||||
|
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
||||||
|
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
|
||||||
const logs = tailFile(logFilePath(), 15);
|
const logs = tailFile(logFilePath(), 15);
|
||||||
throw new Error(`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`);
|
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||||
|
throw new Error(parts.join("\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for PID file with server ID
|
// Poll for PID file with server ID
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { spawnProcess } from "@getpaseo/server";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
import log from "electron-log/main";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CLI_ENV,
|
DESKTOP_CLI_ENV,
|
||||||
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
|
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
|
||||||
@@ -284,7 +285,18 @@ export async function runCliTextCommand(args: string[]): Promise<string> {
|
|||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
const stderr = result.stderr.trim();
|
const stderr = result.stderr.trim();
|
||||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
const stdout = result.stdout.trim();
|
||||||
|
log.warn("[desktop cli]", `CLI text command failed`, {
|
||||||
|
args,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
stdout: stdout.slice(0, 500),
|
||||||
|
stderr: stderr.slice(0, 500),
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
stderr.length > 0
|
||||||
|
? stderr
|
||||||
|
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.stdout.trimEnd();
|
return result.stdout.trimEnd();
|
||||||
@@ -298,11 +310,24 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
|
|||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
const stderr = result.stderr.trim();
|
const stderr = result.stderr.trim();
|
||||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
const stdout = result.stdout.trim();
|
||||||
|
log.warn("[desktop cli]", `CLI command failed`, {
|
||||||
|
args,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
stdout: stdout.slice(0, 500),
|
||||||
|
stderr: stderr.slice(0, 500),
|
||||||
|
command: invocation.command,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
stderr.length > 0
|
||||||
|
? stderr
|
||||||
|
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdout = result.stdout.trim();
|
const stdout = result.stdout.trim();
|
||||||
if (stdout.length === 0) {
|
if (stdout.length === 0) {
|
||||||
|
log.warn("[desktop cli]", `CLI command produced no output`, { args });
|
||||||
throw new Error("CLI command did not produce JSON output.");
|
throw new Error("CLI command did not produce JSON output.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +335,11 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
|
|||||||
// Extract the first valid JSON object or array from the output.
|
// Extract the first valid JSON object or array from the output.
|
||||||
const jsonStart = stdout.search(/[{[]/);
|
const jsonStart = stdout.search(/[{[]/);
|
||||||
if (jsonStart < 0) {
|
if (jsonStart < 0) {
|
||||||
throw new Error("CLI command output contained no JSON.");
|
log.warn("[desktop cli]", `CLI command output contained no JSON`, {
|
||||||
|
args,
|
||||||
|
stdout: stdout.slice(0, 500),
|
||||||
|
});
|
||||||
|
throw new Error(`CLI command output contained no JSON. Output: ${stdout.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
const jsonText = stdout.slice(jsonStart);
|
const jsonText = stdout.slice(jsonStart);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
|
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||||
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
|
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.cpSync('src/server/db/migrations','dist/server/server/db/migrations',{recursive:true});\"",
|
||||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user