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 ReactElement,
|
||||
} from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
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 { DiffStat } from "@/components/diff-stat";
|
||||
import { Pressable } from "react-native";
|
||||
@@ -18,10 +17,7 @@ import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
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 { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
|
||||
interface Rect {
|
||||
@@ -108,8 +104,7 @@ function WorkspaceHoverCardDesktop({
|
||||
const triggerHoveredRef = useRef(false);
|
||||
const contentHoveredRef = useRef(false);
|
||||
|
||||
const hasScripts = workspace.scripts.length > 0;
|
||||
const hasContent = hasScripts || prHint !== null;
|
||||
const hasContent = prHint !== null || !!workspace.diffStat;
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
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({
|
||||
workspace,
|
||||
prHint,
|
||||
@@ -235,39 +204,10 @@ function WorkspaceHoverCardContent({
|
||||
onContentLeave: () => void;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null);
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: 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
|
||||
useEffect(() => {
|
||||
@@ -345,167 +285,6 @@ function WorkspaceHoverCardContent({
|
||||
{prHint ? <PrBadge hint={prHint} /> : null}
|
||||
</View>
|
||||
) : 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 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
@@ -619,30 +398,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 1,
|
||||
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: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -656,27 +411,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "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: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -706,13 +440,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
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",
|
||||
overflowY: "auto",
|
||||
WebkitAppRegion: "no-drag",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
"$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
--kill-others \
|
||||
--names "metro,electron" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
|
||||
@@ -209,6 +209,8 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
|
||||
return {
|
||||
serverId: "",
|
||||
status: "stopped",
|
||||
@@ -218,7 +220,7 @@ async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
||||
home,
|
||||
version: null,
|
||||
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> {
|
||||
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") {
|
||||
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
||||
const daemonVersion = normalizeVersion(current.version);
|
||||
@@ -269,7 +279,16 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
||||
detached: true,
|
||||
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", {
|
||||
@@ -280,41 +299,54 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
child.unref();
|
||||
|
||||
// Wait for process to survive the grace period
|
||||
const exitedEarly = await new Promise<boolean>((resolve) => {
|
||||
type GraceResult =
|
||||
| { exitedEarly: false }
|
||||
| { exitedEarly: true; code: number | null; signal: string | null; error?: Error };
|
||||
|
||||
const result = await new Promise<GraceResult>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
const finish = (value: GraceResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
|
||||
const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS);
|
||||
|
||||
child.once("error", () => {
|
||||
logDesktopDaemonLifecycle("detached child emitted error during grace period", {
|
||||
childPid: child.pid ?? null,
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
finish(true);
|
||||
finish({ exitedEarly: true, code: null, signal: null, error });
|
||||
});
|
||||
child.once("exit", () => {
|
||||
logDesktopDaemonLifecycle("detached child emitted exit during grace period", {
|
||||
childPid: child.pid ?? null,
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
finish(true);
|
||||
finish({ exitedEarly: true, code, signal });
|
||||
});
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||
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);
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { spawnProcess } from "@getpaseo/server";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import {
|
||||
DESKTOP_CLI_ENV,
|
||||
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
|
||||
@@ -284,7 +285,18 @@ export async function runCliTextCommand(args: string[]): Promise<string> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
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();
|
||||
@@ -298,11 +310,24 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
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();
|
||||
if (stdout.length === 0) {
|
||||
log.warn("[desktop cli]", `CLI command produced no output`, { args });
|
||||
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.
|
||||
const jsonStart = stdout.search(/[{[]/);
|
||||
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);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"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",
|
||||
"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');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
||||
|
||||
Reference in New Issue
Block a user