mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix React reload and debug diagnostics
This commit is contained in:
@@ -286,3 +286,116 @@ select:disabled {
|
||||
@utility animate-progress-shimmer {
|
||||
animation: progress-shimmer 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes ow-session-orbit {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-orbit-reverse {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-comet {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate(-50%, -50%) rotate(0deg) translateX(42px) scale(0.72);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(180deg) translateX(42px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(0.86);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-scan {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-22px) scaleX(0.6);
|
||||
}
|
||||
18%,
|
||||
72% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(170px) scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-core {
|
||||
from {
|
||||
transform: rotate(0deg) scale(0.94);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1.04);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg) scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ow-session-progress {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(240%);
|
||||
}
|
||||
}
|
||||
|
||||
.ow-session-orbit {
|
||||
animation: ow-session-orbit 3.8s linear infinite;
|
||||
}
|
||||
|
||||
.ow-session-orbit-reverse {
|
||||
animation: ow-session-orbit-reverse 5.2s linear infinite;
|
||||
}
|
||||
|
||||
.ow-session-comet {
|
||||
animation: ow-session-comet 2.4s cubic-bezier(0.45, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.ow-session-glow {
|
||||
animation: ow-session-glow 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ow-session-scan {
|
||||
animation: ow-session-scan 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ow-session-core {
|
||||
animation: ow-session-core 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ow-session-progress {
|
||||
animation: ow-session-progress 1.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ow-session-wait * {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||
useWorkspaceShellLayout,
|
||||
} from "../../../shell/workspace-shell-layout";
|
||||
import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog";
|
||||
|
||||
type StatusBarOverrides = Pick<
|
||||
StatusBarProps,
|
||||
@@ -196,6 +197,14 @@ export function SessionPage(props: SessionPageProps) {
|
||||
defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||
expandedRightWidth: 280,
|
||||
});
|
||||
useReactRenderWatchdog("SessionPage", {
|
||||
selectedSessionId: props.selectedSessionId,
|
||||
selectedWorkspaceId: props.selectedWorkspaceId,
|
||||
clientConnected: props.clientConnected,
|
||||
startupPhase: props.startupPhase,
|
||||
hasSurface: Boolean(props.surface),
|
||||
workspaceCount: props.workspaces.length,
|
||||
});
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameTitle, setRenameTitle] = useState("");
|
||||
@@ -442,15 +451,29 @@ export function SessionPage(props: SessionPageProps) {
|
||||
) : null}
|
||||
|
||||
{showDelayedSessionLoadingState ? (
|
||||
<div className="px-6 py-24">
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center gap-4 rounded-3xl border border-dls-border bg-dls-hover/60 px-8 py-10 text-center" role="status" aria-live="polite">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface">
|
||||
<Loader2 size={20} className="animate-spin text-dls-secondary" />
|
||||
<div className="px-6 py-20">
|
||||
<div className="ow-session-wait relative mx-auto flex max-w-md flex-col items-center gap-5 overflow-hidden rounded-[32px] border border-dls-border bg-[radial-gradient(circle_at_top,rgba(var(--dls-accent-rgb),0.16),transparent_46%),var(--dls-surface)] px-8 py-10 text-center shadow-[0_24px_80px_rgba(15,23,42,0.16)]" role="status" aria-live="polite">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div className="ow-session-glow absolute left-1/2 top-1/2 h-56 w-56 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[rgba(var(--dls-accent-rgb),0.14)] blur-3xl" />
|
||||
<div className="ow-session-scan absolute inset-x-8 top-8 h-px bg-gradient-to-r from-transparent via-[rgba(var(--dls-accent-rgb),0.65)] to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-24 w-24 items-center justify-center">
|
||||
<div className="ow-session-orbit absolute inset-0 rounded-full border border-[rgba(var(--dls-accent-rgb),0.24)]" />
|
||||
<div className="ow-session-orbit-reverse absolute inset-3 rounded-full border border-dashed border-[rgba(var(--dls-accent-rgb),0.36)]" />
|
||||
<div className="ow-session-comet absolute left-1/2 top-1/2 h-2.5 w-2.5 rounded-full bg-dls-accent shadow-[0_0_20px_rgba(var(--dls-accent-rgb),0.9)]" />
|
||||
<div className="relative flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] backdrop-blur">
|
||||
<div className="h-6 w-6 rounded-lg bg-[conic-gradient(from_0deg,rgba(var(--dls-accent-rgb),0.15),rgba(var(--dls-accent-rgb),0.95),rgba(var(--dls-accent-rgb),0.15))] ow-session-core" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-medium text-dls-text">{t("session.loading_title")}</h3>
|
||||
<p className="text-sm text-dls-secondary">{t("session.loading_detail")}</p>
|
||||
</div>
|
||||
<div className="relative h-1.5 w-full max-w-[260px] overflow-hidden rounded-full bg-dls-hover">
|
||||
<div className="ow-session-progress absolute inset-y-0 left-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-dls-accent to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { getReactQueryClient } from "../../../infra/query-client";
|
||||
import { ReactSessionComposer } from "./composer/composer";
|
||||
import { DevProfiler } from "../../../shell/dev-profiler";
|
||||
import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog";
|
||||
import type { ReactComposerNotice } from "./composer/notice";
|
||||
import { SessionDebugPanel } from "./debug-panel";
|
||||
import { SessionTranscript } from "./message-list";
|
||||
@@ -106,6 +107,40 @@ function useSharedQueryState<T>(queryKey: readonly unknown[], fallback: T) {
|
||||
);
|
||||
}
|
||||
|
||||
function messageHasVisibleAssistantOutput(message: UIMessage) {
|
||||
if (message.role !== "assistant") return false;
|
||||
return message.parts.some((part) => {
|
||||
if ("text" in part && typeof part.text === "string") return part.text.trim().length > 0;
|
||||
return part.type === "dynamic-tool" || part.type === "file";
|
||||
});
|
||||
}
|
||||
|
||||
function AssistantWaitingCard() {
|
||||
return (
|
||||
<div className="flex justify-start py-3" role="status" aria-live="polite">
|
||||
<div className="ow-session-wait relative flex max-w-[360px] items-center gap-4 overflow-hidden rounded-[26px] border border-dls-border bg-[radial-gradient(circle_at_top_left,rgba(var(--dls-accent-rgb),0.16),transparent_46%),var(--dls-surface)] px-5 py-4 shadow-[0_18px_56px_rgba(15,23,42,0.12)]">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-70">
|
||||
<div className="ow-session-glow absolute left-12 top-1/2 h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[rgba(var(--dls-accent-rgb),0.16)] blur-2xl" />
|
||||
<div className="ow-session-scan absolute inset-x-4 top-4 h-px bg-gradient-to-r from-transparent via-[rgba(var(--dls-accent-rgb),0.62)] to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex h-14 w-14 shrink-0 items-center justify-center">
|
||||
<div className="ow-session-orbit absolute inset-0 rounded-full border border-[rgba(var(--dls-accent-rgb),0.24)]" />
|
||||
<div className="ow-session-orbit-reverse absolute inset-2 rounded-full border border-dashed border-[rgba(var(--dls-accent-rgb),0.36)]" />
|
||||
<div className="ow-session-comet absolute left-1/2 top-1/2 h-2 w-2 rounded-full bg-dls-accent shadow-[0_0_18px_rgba(var(--dls-accent-rgb),0.9)]" />
|
||||
<div className="relative h-7 w-7 rounded-lg bg-[conic-gradient(from_0deg,rgba(var(--dls-accent-rgb),0.15),rgba(var(--dls-accent-rgb),0.95),rgba(var(--dls-accent-rgb),0.15))] ow-session-core" />
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1 text-left">
|
||||
<div className="text-sm font-medium text-dls-text">Thinking...</div>
|
||||
<div className="mt-0.5 text-xs text-dls-secondary">Waiting for the first response token</div>
|
||||
<div className="relative mt-3 h-1 w-full overflow-hidden rounded-full bg-dls-hover">
|
||||
<div className="ow-session-progress absolute inset-y-0 left-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-dls-accent to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) {
|
||||
if (!attachment.previewUrl) return;
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
@@ -120,6 +155,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showDelayedLoading, setShowDelayedLoading] = useState(false);
|
||||
const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState<number | null>(null);
|
||||
const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null);
|
||||
const [toolSkills, setToolSkills] = useState<SkillCard[]>([]);
|
||||
const [toolMcpServers, setToolMcpServers] = useState<McpServerEntry[]>([]);
|
||||
@@ -181,6 +217,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
setError(null);
|
||||
setSending(false);
|
||||
setShowDelayedLoading(false);
|
||||
setAwaitingAssistantBaseline(null);
|
||||
// Clear draft + attachments + mentions on session change so typed text
|
||||
// doesn't bleed across sessions (and across workspaces). The sessionId
|
||||
// effectively changes when the workspace changes too because the route
|
||||
@@ -270,6 +307,23 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry";
|
||||
const renderedMessages = transcriptState ?? [];
|
||||
const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0;
|
||||
const assistantOutputAfterAwaitStart = useMemo(() => {
|
||||
if (awaitingAssistantBaseline === null) return false;
|
||||
return renderedMessages
|
||||
.slice(awaitingAssistantBaseline)
|
||||
.some(messageHasVisibleAssistantOutput);
|
||||
}, [awaitingAssistantBaseline, renderedMessages]);
|
||||
const showAssistantWaitState = awaitingAssistantBaseline !== null && !assistantOutputAfterAwaitStart;
|
||||
useReactRenderWatchdog("SessionSurface", {
|
||||
sessionId: props.sessionId,
|
||||
workspaceId: props.workspaceId,
|
||||
messageCount: renderedMessages.length,
|
||||
liveStatus: liveStatus.type,
|
||||
sending,
|
||||
pendingSessionLoad,
|
||||
showAssistantWaitState,
|
||||
hasSnapshot: Boolean(snapshot),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSessionLoad) {
|
||||
@@ -280,6 +334,17 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [pendingSessionLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingAssistantBaseline === null) return;
|
||||
if (assistantOutputAfterAwaitStart) {
|
||||
setAwaitingAssistantBaseline(null);
|
||||
return;
|
||||
}
|
||||
if (sending || liveStatus.type !== "idle" || renderedMessages.length <= awaitingAssistantBaseline) return;
|
||||
const id = window.setTimeout(() => setAwaitingAssistantBaseline(null), 1200);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [assistantOutputAfterAwaitStart, awaitingAssistantBaseline, liveStatus.type, renderedMessages.length, sending]);
|
||||
|
||||
const model = deriveSessionRenderModel({
|
||||
intendedSessionId: props.sessionId,
|
||||
renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null,
|
||||
@@ -336,6 +401,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
// talking" behavior that the Solid composer had.
|
||||
setError(null);
|
||||
setSending(true);
|
||||
setAwaitingAssistantBaseline(renderedMessages.length);
|
||||
try {
|
||||
const nextDraft = buildDraft(text, attachments);
|
||||
await props.onSendDraft(nextDraft);
|
||||
@@ -346,6 +412,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
setSending(false);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
|
||||
setAwaitingAssistantBaseline(null);
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
@@ -560,20 +627,23 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
||||
{error || (snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load React session view.")}
|
||||
</div>
|
||||
</div>
|
||||
) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
|
||||
<div className="px-6 py-16">
|
||||
<div className="mx-auto max-w-sm rounded-3xl border border-dls-border bg-dls-hover/60 px-8 py-10 text-center">
|
||||
<div className="text-sm text-dls-secondary">No transcript yet.</div>
|
||||
</div>
|
||||
) : renderedMessages.length === 0 && showAssistantWaitState ? (
|
||||
<div className="px-6 py-12">
|
||||
<AssistantWaitingCard />
|
||||
</div>
|
||||
) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
|
||||
null
|
||||
) : (
|
||||
<DevProfiler id="SessionTranscript">
|
||||
<SessionTranscript
|
||||
messages={renderedMessages}
|
||||
isStreaming={chatStreaming}
|
||||
developerMode={props.developerMode}
|
||||
scrollElement={() => scrollRef.current}
|
||||
/>
|
||||
<>
|
||||
<SessionTranscript
|
||||
messages={renderedMessages}
|
||||
isStreaming={chatStreaming}
|
||||
developerMode={props.developerMode}
|
||||
scrollElement={() => scrollRef.current}
|
||||
/>
|
||||
{showAssistantWaitState ? <AssistantWaitingCard /> : null}
|
||||
</>
|
||||
</DevProfiler>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,11 @@ export type ResetState = {
|
||||
|
||||
export type SystemStateControls = {
|
||||
reload: ReloadState;
|
||||
reloadCopy: { title: string; body: string };
|
||||
markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void;
|
||||
clearReloadRequired: () => void;
|
||||
reloadWorkspaceEngine: () => Promise<void>;
|
||||
canReloadWorkspaceEngine: boolean;
|
||||
reset: ResetState;
|
||||
openResetModal: (mode: ResetOpenworkMode) => void;
|
||||
closeResetModal: () => void;
|
||||
@@ -60,6 +63,9 @@ function clearOpenworkLocalStorage(mode: ResetOpenworkMode) {
|
||||
|
||||
type UseSystemStateOptions = {
|
||||
hasActiveRuns: () => boolean;
|
||||
reloadWorkspaceEngine?: () => Promise<boolean>;
|
||||
canReloadWorkspaceEngine?: () => boolean;
|
||||
onReloadComplete?: () => void | Promise<void>;
|
||||
setError: (message: string | null) => void;
|
||||
};
|
||||
|
||||
@@ -72,8 +78,8 @@ export function useSystemState(
|
||||
number | null
|
||||
>(null);
|
||||
const [reloadTrigger, setReloadTrigger] = useState<ReloadTrigger | null>(null);
|
||||
const [reloadBusy] = useState(false);
|
||||
const [reloadError] = useState<string | null>(null);
|
||||
const [reloadBusy, setReloadBusy] = useState(false);
|
||||
const [reloadError, setReloadError] = useState<string | null>(null);
|
||||
|
||||
const [resetModalOpen, setResetModalOpen] = useState(false);
|
||||
const [resetModalMode, setResetModalMode] =
|
||||
@@ -111,8 +117,60 @@ export function useSystemState(
|
||||
setReloadPending(false);
|
||||
setReloadReasons([]);
|
||||
setReloadTrigger(null);
|
||||
setReloadError(null);
|
||||
}, []);
|
||||
|
||||
const reloadCopy = useMemo(() => {
|
||||
const title = t("system.reload_required");
|
||||
const bodyKey =
|
||||
reloadReasons.length === 1 && reloadReasons[0] === "plugins"
|
||||
? "system.reload_body_plugins"
|
||||
: reloadReasons.length === 1 && reloadReasons[0] === "skills"
|
||||
? "system.reload_body_skills"
|
||||
: reloadReasons.length === 1 && reloadReasons[0] === "agents"
|
||||
? "system.reload_body_agents"
|
||||
: reloadReasons.length === 1 && reloadReasons[0] === "commands"
|
||||
? "system.reload_body_commands"
|
||||
: reloadReasons.length === 1 && reloadReasons[0] === "config"
|
||||
? "system.reload_body_config"
|
||||
: reloadReasons.length === 1 && reloadReasons[0] === "mcp"
|
||||
? "system.reload_body_mcp"
|
||||
: reloadReasons.length > 0
|
||||
? "system.reload_body_mixed"
|
||||
: "system.reload_body_default";
|
||||
return { title, body: t(bodyKey) };
|
||||
}, [reloadReasons]);
|
||||
|
||||
const canReloadWorkspaceEngine =
|
||||
!reloadBusy && options.canReloadWorkspaceEngine?.() !== false;
|
||||
|
||||
const reloadWorkspaceEngine = useCallback(async () => {
|
||||
if (reloadBusy) return;
|
||||
if (options.canReloadWorkspaceEngine?.() === false) {
|
||||
setReloadError(t("system.reload_unavailable"));
|
||||
return;
|
||||
}
|
||||
setReloadBusy(true);
|
||||
setReloadError(null);
|
||||
options.setError(null);
|
||||
try {
|
||||
const ok = options.reloadWorkspaceEngine
|
||||
? await options.reloadWorkspaceEngine()
|
||||
: false;
|
||||
if (ok === false) {
|
||||
setReloadError(t("system.reload_failed"));
|
||||
return;
|
||||
}
|
||||
await options.onReloadComplete?.();
|
||||
clearReloadRequired();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : safeStringify(error);
|
||||
setReloadError(message || t("system.reload_failed"));
|
||||
} finally {
|
||||
setReloadBusy(false);
|
||||
}
|
||||
}, [clearReloadRequired, options, reloadBusy]);
|
||||
|
||||
const openResetModal = useCallback(
|
||||
(mode: ResetOpenworkMode) => {
|
||||
if (options.hasActiveRuns()) {
|
||||
@@ -171,8 +229,11 @@ export function useSystemState(
|
||||
reloadBusy,
|
||||
reloadError,
|
||||
},
|
||||
reloadCopy,
|
||||
markReloadRequired,
|
||||
clearReloadRequired,
|
||||
reloadWorkspaceEngine,
|
||||
canReloadWorkspaceEngine,
|
||||
reset: {
|
||||
resetModalOpen,
|
||||
resetModalMode,
|
||||
@@ -192,7 +253,10 @@ export function useSystemState(
|
||||
markReloadRequired,
|
||||
openResetModal,
|
||||
options.setError,
|
||||
reloadCopy,
|
||||
reloadBusy,
|
||||
reloadWorkspaceEngine,
|
||||
canReloadWorkspaceEngine,
|
||||
reloadError,
|
||||
reloadLastTriggeredAt,
|
||||
reloadPending,
|
||||
|
||||
@@ -173,6 +173,14 @@ function enqueue(entry: DevLogEntry) {
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function recordDebugLog(entry: DevLogEntry) {
|
||||
if (!started || !isEnabled()) {
|
||||
recordInspectorEvent(`log.${entry.level}`, entry);
|
||||
return;
|
||||
}
|
||||
enqueue(entry);
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// Always on in dev; explicit opt-out via `localStorage.openwork.debug.disableLogger = "1"`.
|
||||
|
||||
101
apps/app/src/react-app/shell/react-render-watchdog.ts
Normal file
101
apps/app/src/react-app/shell/react-render-watchdog.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/** @jsxImportSource react */
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { publishInspectorSlice, recordInspectorEvent } from "./app-inspector";
|
||||
import { recordDebugLog } from "./debug-logger";
|
||||
|
||||
type RenderWatchdogDetails = Record<string, unknown>;
|
||||
|
||||
type RenderWatchdogStats = {
|
||||
name: string;
|
||||
totalCommits: number;
|
||||
windowCommits: number;
|
||||
windowStartedAt: number;
|
||||
lastCommitAt: number;
|
||||
lastWarnAt: number;
|
||||
lastDetails?: RenderWatchdogDetails;
|
||||
};
|
||||
|
||||
const WINDOW_MS = 2_000;
|
||||
const WARN_COMMIT_THRESHOLD = 40;
|
||||
const WARN_COOLDOWN_MS = 5_000;
|
||||
const statsByName = new Map<string, RenderWatchdogStats>();
|
||||
let inspectorInstalled = false;
|
||||
|
||||
function compactStats() {
|
||||
return Array.from(statsByName.values())
|
||||
.map((stats) => ({
|
||||
name: stats.name,
|
||||
totalCommits: stats.totalCommits,
|
||||
windowCommits: stats.windowCommits,
|
||||
windowAgeMs: Math.max(0, Date.now() - stats.windowStartedAt),
|
||||
lastCommitAgeMs: Math.max(0, Date.now() - stats.lastCommitAt),
|
||||
lastWarnAgeMs: stats.lastWarnAt > 0 ? Math.max(0, Date.now() - stats.lastWarnAt) : null,
|
||||
lastDetails: stats.lastDetails ?? null,
|
||||
}))
|
||||
.sort((a, b) => b.windowCommits - a.windowCommits || b.totalCommits - a.totalCommits);
|
||||
}
|
||||
|
||||
function installInspectorSlice() {
|
||||
if (inspectorInstalled) return;
|
||||
inspectorInstalled = true;
|
||||
publishInspectorSlice("reactRenderWatchdog", () => ({
|
||||
windowMs: WINDOW_MS,
|
||||
warnCommitThreshold: WARN_COMMIT_THRESHOLD,
|
||||
components: compactStats(),
|
||||
}));
|
||||
}
|
||||
|
||||
function recordCommit(name: string, details?: RenderWatchdogDetails) {
|
||||
installInspectorSlice();
|
||||
const now = Date.now();
|
||||
let stats = statsByName.get(name);
|
||||
if (!stats) {
|
||||
stats = {
|
||||
name,
|
||||
totalCommits: 0,
|
||||
windowCommits: 0,
|
||||
windowStartedAt: now,
|
||||
lastCommitAt: now,
|
||||
lastWarnAt: 0,
|
||||
};
|
||||
statsByName.set(name, stats);
|
||||
}
|
||||
|
||||
if (now - stats.windowStartedAt > WINDOW_MS) {
|
||||
stats.windowStartedAt = now;
|
||||
stats.windowCommits = 0;
|
||||
}
|
||||
|
||||
stats.totalCommits += 1;
|
||||
stats.windowCommits += 1;
|
||||
stats.lastCommitAt = now;
|
||||
stats.lastDetails = details;
|
||||
|
||||
if (
|
||||
stats.windowCommits >= WARN_COMMIT_THRESHOLD &&
|
||||
now - stats.lastWarnAt > WARN_COOLDOWN_MS
|
||||
) {
|
||||
stats.lastWarnAt = now;
|
||||
const payload = {
|
||||
component: name,
|
||||
windowCommits: stats.windowCommits,
|
||||
windowMs: WINDOW_MS,
|
||||
totalCommits: stats.totalCommits,
|
||||
details: details ?? null,
|
||||
};
|
||||
recordInspectorEvent("react.render_loop_suspected", payload);
|
||||
recordDebugLog({
|
||||
level: "warn",
|
||||
source: "react-render-watchdog",
|
||||
message: "react.render_loop_suspected",
|
||||
extra: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useReactRenderWatchdog(name: string, details?: RenderWatchdogDetails) {
|
||||
useEffect(() => {
|
||||
recordCommit(name, details);
|
||||
});
|
||||
}
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
publishInspectorSlice,
|
||||
recordInspectorEvent,
|
||||
} from "./app-inspector";
|
||||
import { useReactRenderWatchdog } from "./react-render-watchdog";
|
||||
import { getModelBehaviorSummary } from "../../app/lib/model-behavior";
|
||||
import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers";
|
||||
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
||||
@@ -320,6 +321,15 @@ export function SessionRoute() {
|
||||
// behavior pill actually shows its options (bug: was empty before).
|
||||
const [providerCatalog, setProviderCatalog] = useState<Record<string, Record<string, any>>>({});
|
||||
const [openworkServerHostInfoState, setOpenworkServerHostInfoState] = useState<OpenworkServerInfo | null>(null);
|
||||
useReactRenderWatchdog("SessionRoute", {
|
||||
selectedSessionId,
|
||||
selectedWorkspaceId,
|
||||
loading,
|
||||
workspaceCount: workspaces.length,
|
||||
sessionGroupCount: Object.keys(sessionsByWorkspaceId).length,
|
||||
commandPaletteOpen,
|
||||
modelPickerOpen,
|
||||
});
|
||||
const [openworkServerSettingsVersion, setOpenworkServerSettingsVersion] = useState(0);
|
||||
const [routeEngineInfo, setRouteEngineInfo] = useState<EngineInfo | null>(null);
|
||||
const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = useState(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @jsxImportSource react */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { SUGGESTED_PLUGINS } from "../../app/constants";
|
||||
@@ -21,6 +21,7 @@ import { createOpenworkServerStore, useOpenworkServerStoreSnapshot } from "../do
|
||||
import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "../domains/connections/provider-auth/store";
|
||||
import ProviderAuthModal from "../domains/connections/provider-auth/provider-auth-modal";
|
||||
import ConnectionsModals from "../domains/connections/modals";
|
||||
import { TopRightNotifications } from "../domains/shell-feedback/top-right-notifications";
|
||||
import { GeneralSettingsView } from "../domains/settings/pages/general-view";
|
||||
import { AdvancedView } from "../domains/settings/pages/advanced-view";
|
||||
import { AppearanceView } from "../domains/settings/pages/appearance-view";
|
||||
@@ -38,6 +39,7 @@ import { SettingsShell } from "../domains/settings/shell/settings-shell";
|
||||
import { createAutomationsStore, useAutomationsStoreSnapshot } from "../domains/settings/state/automations-store";
|
||||
import { createExtensionsStore, useExtensionsStoreSnapshot } from "../domains/settings/state/extensions-store";
|
||||
import { usePlatform } from "../kernel/platform";
|
||||
import { useSystemState } from "../kernel/system-state";
|
||||
import { useLocal } from "../kernel/local-provider";
|
||||
import {
|
||||
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||
@@ -65,6 +67,7 @@ import { ModelPickerModal } from "../domains/session/modals/model-picker-modal";
|
||||
import type { ModelOption, ModelRef } from "../../app/types";
|
||||
import { recordInspectorEvent } from "./app-inspector";
|
||||
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
||||
import { abortSessionSafe } from "../../app/lib/opencode-session";
|
||||
|
||||
type RouteWorkspace = OpenworkWorkspaceInfo & {
|
||||
displayNameResolved: string;
|
||||
@@ -192,6 +195,14 @@ function toSessionGroups(
|
||||
}));
|
||||
}
|
||||
|
||||
function isActiveSessionStatus(status: unknown) {
|
||||
return status === "running" || status === "retry" || status === "busy";
|
||||
}
|
||||
|
||||
function getSessionStatus(session: any) {
|
||||
return session?.status ?? session?.state ?? session?.runStatus ?? null;
|
||||
}
|
||||
|
||||
function parseSettingsPath(pathname: string): {
|
||||
tab: SettingsTab;
|
||||
redirectPath: string | null;
|
||||
@@ -375,6 +386,55 @@ export function SettingsRoute() {
|
||||
developerMode,
|
||||
};
|
||||
|
||||
const activeReloadBlockingSessions = useMemo(
|
||||
() =>
|
||||
Object.values(sessionsByWorkspaceId)
|
||||
.flat()
|
||||
.filter((session) => isActiveSessionStatus(getSessionStatus(session)))
|
||||
.map((session: any) => ({
|
||||
id: String(session?.id ?? ""),
|
||||
title:
|
||||
String(session?.title ?? session?.slug ?? session?.id ?? "").trim() ||
|
||||
t("session.untitled"),
|
||||
}))
|
||||
.filter((session) => session.id.length > 0),
|
||||
[sessionsByWorkspaceId],
|
||||
);
|
||||
|
||||
const reloadWorkspaceEngineFromUi = useCallback(async () => {
|
||||
const workspaceId = routeStateRef.current.runtimeWorkspaceId?.trim() || selectedWorkspaceId.trim();
|
||||
if (!openworkClient || !workspaceId) {
|
||||
setRouteError(t("app.error_connect_first"));
|
||||
return false;
|
||||
}
|
||||
|
||||
await openworkClient.reloadEngine(workspaceId);
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("openwork-server-settings-changed"));
|
||||
} catch {
|
||||
// ignore browser event dispatch failures
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [openworkClient, selectedWorkspaceId]);
|
||||
|
||||
const systemState = useSystemState({
|
||||
hasActiveRuns: () => activeReloadBlockingSessions.length > 0,
|
||||
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
|
||||
canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)),
|
||||
setError: setRouteError,
|
||||
});
|
||||
|
||||
const forceStopActiveSessionsAndReload = useCallback(async () => {
|
||||
if (activeClient) {
|
||||
for (const session of activeReloadBlockingSessions) {
|
||||
await abortSessionSafe(activeClient, session.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await systemState.reloadWorkspaceEngine();
|
||||
}, [activeClient, activeReloadBlockingSessions, systemState.reloadWorkspaceEngine]);
|
||||
|
||||
const shellLayout = useWorkspaceShellLayout({
|
||||
expandedRightWidth: 320,
|
||||
defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||
@@ -414,8 +474,9 @@ export function SettingsRoute() {
|
||||
openworkServer: openworkServerStore,
|
||||
runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId,
|
||||
developerMode: () => routeStateRef.current.developerMode,
|
||||
markReloadRequired: systemState.markReloadRequired,
|
||||
}),
|
||||
[openworkServerStore],
|
||||
[openworkServerStore, systemState.markReloadRequired],
|
||||
);
|
||||
const providerAuthStore = useMemo(
|
||||
() =>
|
||||
@@ -435,9 +496,14 @@ export function SettingsRoute() {
|
||||
setDisabledProviders,
|
||||
markOpencodeConfigReloadRequired: () => {
|
||||
setConfigActionStatus(t("settings.config_updated"));
|
||||
systemState.markReloadRequired("config", {
|
||||
type: "config",
|
||||
name: "opencode.json",
|
||||
action: "updated",
|
||||
});
|
||||
},
|
||||
}),
|
||||
[openworkServerStore],
|
||||
[openworkServerStore, systemState.markReloadRequired],
|
||||
);
|
||||
const extensionsStore = useMemo(
|
||||
() =>
|
||||
@@ -453,8 +519,9 @@ export function SettingsRoute() {
|
||||
setBusyLabel,
|
||||
setBusyStartedAt: () => {},
|
||||
setError: setRouteError,
|
||||
markReloadRequired: systemState.markReloadRequired,
|
||||
}),
|
||||
[openworkServerStore],
|
||||
[openworkServerStore, systemState.markReloadRequired],
|
||||
);
|
||||
const automationsStore = useMemo(
|
||||
() =>
|
||||
@@ -937,11 +1004,9 @@ export function SettingsRoute() {
|
||||
addPlugin={async () => {
|
||||
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
|
||||
}}
|
||||
reloadWorkspaceEngine={async () => {
|
||||
setRouteError("Workspace reload is not wired into the React settings route yet.");
|
||||
}}
|
||||
reloadBusy={false}
|
||||
canReloadWorkspace={false}
|
||||
reloadWorkspaceEngine={systemState.reloadWorkspaceEngine}
|
||||
reloadBusy={systemState.reload.reloadBusy}
|
||||
canReloadWorkspace={systemState.canReloadWorkspaceEngine}
|
||||
openLink={(url) => platform.openLink(url)}
|
||||
/>
|
||||
);
|
||||
@@ -1065,12 +1130,10 @@ export function SettingsRoute() {
|
||||
updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings,
|
||||
resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings,
|
||||
testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection,
|
||||
canReloadWorkspace: false,
|
||||
reloadWorkspaceEngine: async () => {
|
||||
setRouteError("Workspace reload is not wired into the React settings route yet.");
|
||||
},
|
||||
reloadBusy: false,
|
||||
reloadError: routeError,
|
||||
canReloadWorkspace: systemState.canReloadWorkspaceEngine,
|
||||
reloadWorkspaceEngine: systemState.reloadWorkspaceEngine,
|
||||
reloadBusy: systemState.reload.reloadBusy,
|
||||
reloadError: systemState.reload.reloadError ?? routeError,
|
||||
developerMode,
|
||||
}}
|
||||
/>
|
||||
@@ -1236,15 +1299,40 @@ export function SettingsRoute() {
|
||||
remoteSubmitting={createWorkspaceRemoteBusy}
|
||||
remoteError={createWorkspaceRemoteError}
|
||||
/>
|
||||
<TopRightNotifications
|
||||
reloadOpen={systemState.reload.reloadPending}
|
||||
reloadTitle={systemState.reloadCopy.title}
|
||||
reloadDescription={systemState.reloadCopy.body}
|
||||
reloadTrigger={systemState.reload.reloadTrigger}
|
||||
reloadError={systemState.reload.reloadError}
|
||||
reloadLabel={
|
||||
activeReloadBlockingSessions.length > 0
|
||||
? t("app.reload_stop_tasks")
|
||||
: t("app.reload_now")
|
||||
}
|
||||
dismissLabel={t("app.reload_later")}
|
||||
reloadBusy={systemState.reload.reloadBusy}
|
||||
canReload={systemState.canReloadWorkspaceEngine}
|
||||
hasActiveRuns={activeReloadBlockingSessions.length > 0}
|
||||
onReload={() => {
|
||||
void (activeReloadBlockingSessions.length > 0
|
||||
? forceStopActiveSessionsAndReload()
|
||||
: systemState.reloadWorkspaceEngine());
|
||||
}}
|
||||
onDismissReload={systemState.clearReloadRequired}
|
||||
/>
|
||||
<ConnectionsModals
|
||||
client={activeClient}
|
||||
projectDir={selectedWorkspaceRoot}
|
||||
language={currentLocale() as Language}
|
||||
reloadBlocked={false}
|
||||
activeSessions={[]}
|
||||
reloadBlocked={activeReloadBlockingSessions.length > 0}
|
||||
activeSessions={activeReloadBlockingSessions}
|
||||
isRemoteWorkspace={selectedWorkspace?.workspaceType === "remote"}
|
||||
onForceStopSession={() => undefined}
|
||||
onReloadEngine={() => undefined}
|
||||
onForceStopSession={(sessionId) => {
|
||||
if (!activeClient) return undefined;
|
||||
return abortSessionSafe(activeClient, sessionId);
|
||||
}}
|
||||
onReloadEngine={systemState.reloadWorkspaceEngine}
|
||||
modalState={{
|
||||
mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen,
|
||||
mcpAuthEntry: connectionsSnapshot.mcpAuthEntry,
|
||||
|
||||
@@ -287,6 +287,17 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
||||
const orchestratorState = createOrchestratorState();
|
||||
const routerState = createRouterState();
|
||||
|
||||
// Serialize engine lifecycle operations. Without this, concurrent renderer
|
||||
// invocations of engineStart/engineStop/engineRestart race: each call's
|
||||
// stopAllRuntimeChildren kills the previous call's freshly-spawned
|
||||
// orchestrator daemon, and the prior call then times out its /health probe.
|
||||
let runtimeLifecycleQueue = Promise.resolve();
|
||||
function withRuntimeLifecycle(fn) {
|
||||
const next = runtimeLifecycleQueue.then(fn, fn);
|
||||
runtimeLifecycleQueue = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
const userDataDir = app.getPath("userData");
|
||||
const sidecarDirs = [
|
||||
path.join(desktopRoot, "src-tauri", "sidecars"),
|
||||
@@ -1526,9 +1537,9 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
||||
}
|
||||
|
||||
return {
|
||||
engineStart,
|
||||
engineStop,
|
||||
engineRestart,
|
||||
engineStart: (projectDir, options) => withRuntimeLifecycle(() => engineStart(projectDir, options)),
|
||||
engineStop: () => withRuntimeLifecycle(() => engineStop()),
|
||||
engineRestart: (options) => withRuntimeLifecycle(() => engineRestart(options)),
|
||||
engineInfo,
|
||||
engineInstall,
|
||||
openworkServerInfo,
|
||||
|
||||
@@ -9,18 +9,28 @@
|
||||
# tail live tail pnpm dev + the /dev/log sink
|
||||
# sink print the dev log sink path
|
||||
# kill-orphans remove orphan openwork/opencode processes (ppid == launchd)
|
||||
# diagnose-hang classify Electron crash/hang/sidecar/app-state failures
|
||||
# stop full, layered teardown of the dev stack (no cache wipe)
|
||||
# start launch pnpm dev in the background with the log sink on
|
||||
# wait-healthy block until openwork-server reports /health = 200
|
||||
# reset stop + wipe Vite dep cache + truncate log sink + start
|
||||
# restart alias for reset
|
||||
#
|
||||
# Teardown ordering (important):
|
||||
# 1. pnpm dev (parent supervisor)
|
||||
# 2. tauri dev (Rust dev runner, if still alive)
|
||||
# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/
|
||||
# 4. Vite (node node_modules/.../vite)
|
||||
# 5. orchestrator + openwork-server + opencode + opencode-router orphans
|
||||
# Variant (OPENWORK_DEV_VARIANT):
|
||||
# electron (default) pnpm --filter @openwork/desktop dev:electron
|
||||
# Electron shell + CDP on 127.0.0.1:9823 for chrome-devtools MCP.
|
||||
# Sidecars run from apps/desktop/src-tauri/sidecars/*.
|
||||
# tauri legacy: pnpm dev (Tauri dev webview, no CDP).
|
||||
# Sidecars run from apps/desktop/src-tauri/target/debug/*.
|
||||
#
|
||||
# Teardown ordering (important, both variants):
|
||||
# 1. pnpm dev / dev:electron supervisor (parent supervisor)
|
||||
# 2. tauri dev (Rust dev runner, if still alive)
|
||||
# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/
|
||||
# 4. Electron main+helpers (node_modules/electron/...Electron.app)
|
||||
# 5. Vite (node node_modules/.../vite)
|
||||
# 6. orchestrator + openwork-server + opencode + opencode-router
|
||||
# (both target/debug/* and src-tauri/sidecars/* trees)
|
||||
#
|
||||
# Cache/ephemeral state wiped by `reset`:
|
||||
# - Vite dep pre-bundle cache: apps/app/node_modules/.vite
|
||||
@@ -49,6 +59,17 @@ DEV_LOG_FILE="${OPENWORK_DEV_LOG_FILE:-$HOME/.openwork/debug/openwork-dev.log}"
|
||||
PNPM_DEV_LOG="${OPENWORK_PNPM_DEV_LOG:-/tmp/openwork-test/pnpm-dev.log}"
|
||||
PNPM_DEV_PID_FILE="${OPENWORK_PNPM_DEV_PID:-/tmp/openwork-test/pnpm-dev.pid}"
|
||||
WAIT_HEALTHY_SECS="${OPENWORK_WAIT_HEALTHY_SECS:-90}"
|
||||
ELECTRON_CDP_PORT="${OPENWORK_ELECTRON_REMOTE_DEBUG_PORT:-9823}"
|
||||
|
||||
# Dev variant. 'electron' (default) launches pnpm dev:electron with CDP on
|
||||
# 127.0.0.1:9823 so chrome-devtools MCP can attach. 'tauri' preserves the
|
||||
# legacy pnpm dev (Tauri webview) for users still on that path.
|
||||
DEV_VARIANT="${OPENWORK_DEV_VARIANT:-electron}"
|
||||
case "$DEV_VARIANT" in electron|tauri) ;; *)
|
||||
printf '[openwork-debug] unknown OPENWORK_DEV_VARIANT=%s (expected electron|tauri)\n' "$DEV_VARIANT" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -90,16 +111,92 @@ kill_pid_file() {
|
||||
}
|
||||
|
||||
discover_openwork_server_port() {
|
||||
ps -Ao command | grep "target/debug/openwork-server" | grep -v grep \
|
||||
| grep -oE '\-\-port [0-9]+' | head -1 | awk '{print $2}'
|
||||
ps -Ao command \
|
||||
| grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-server" \
|
||||
| grep -v grep \
|
||||
| grep -oE '\-\-port [0-9]+' \
|
||||
| head -1 \
|
||||
| awk '{print $2}' \
|
||||
|| true
|
||||
}
|
||||
|
||||
electron_renderer_stats() {
|
||||
ps -axo pid,ppid,pcpu,pmem,rss,command \
|
||||
| awk '/Electron Helper \(Renderer\)/ && /com\.differentai\.openwork/ && !/awk/ && !/grep/ {print; found=1} END {exit found ? 0 : 1}' \
|
||||
|| true
|
||||
}
|
||||
|
||||
electron_renderer_pid() {
|
||||
electron_renderer_stats | awk 'NR == 1 {print $1}'
|
||||
}
|
||||
|
||||
electron_renderer_cpu() {
|
||||
electron_renderer_stats | awk 'NR == 1 {print $3}'
|
||||
}
|
||||
|
||||
probe_electron_page_cdp() {
|
||||
node <<'NODE'
|
||||
const port = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT || "9823";
|
||||
const controller = new AbortController();
|
||||
const fail = (message) => {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
};
|
||||
const timeout = setTimeout(() => controller.abort(), 1800);
|
||||
let targets;
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: controller.signal });
|
||||
targets = await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
fail(`target-list-failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
||||
if (!page) fail("no-page-target");
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { ws.close(); } catch {}
|
||||
fail("page-cdp-timeout");
|
||||
}, 2200);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1+1", returnByValue: true } }));
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fail("page-cdp-websocket-error");
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id !== 1) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { ws.close(); } catch {}
|
||||
if (message.error) fail(`page-cdp-error: ${message.error.message ?? JSON.stringify(message.error)}`);
|
||||
console.log("ok");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fail(`page-cdp-parse-error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
NODE
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public subcommands
|
||||
|
||||
snapshot() {
|
||||
echo "=== dev stack processes (target/debug tree) ==="
|
||||
ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|vite|pnpm dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20
|
||||
echo "=== dev stack processes ==="
|
||||
ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|node_modules\/electron\/dist\/Electron\.app\/Contents\/MacOS\/Electron|apps\/desktop\/scripts\/electron-dev\.mjs|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|apps\/desktop\/src-tauri\/sidecars\/openwork-server|apps\/desktop\/src-tauri\/sidecars\/openwork-orchestrator|apps\/desktop\/src-tauri\/sidecars\/opencode( |\/)|apps\/desktop\/src-tauri\/sidecars\/opencode-router|vite|pnpm .*dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20
|
||||
|
||||
echo
|
||||
echo "=== openwork-server ==="
|
||||
@@ -116,7 +213,13 @@ snapshot() {
|
||||
echo
|
||||
echo "=== opencode (via orchestrator) ==="
|
||||
local oc_port
|
||||
oc_port=$(ps -Ao command | grep "target/debug/openwork-orchestrator" | grep -v grep | grep -oE '\-\-opencode-port [0-9]+' | head -1 | awk '{print $2}')
|
||||
oc_port=$(ps -Ao command \
|
||||
| grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-orchestrator" \
|
||||
| grep -v grep \
|
||||
| grep -oE '\-\-opencode-port [0-9]+' \
|
||||
| head -1 \
|
||||
| awk '{print $2}' \
|
||||
|| true)
|
||||
if [[ -z "$oc_port" ]]; then
|
||||
echo " (no opencode port)"
|
||||
else
|
||||
@@ -128,7 +231,13 @@ snapshot() {
|
||||
echo
|
||||
echo "=== opencode-router ==="
|
||||
local r_port
|
||||
r_port=$(ps -Ao command | grep "target/debug/opencode-router" | grep -v grep | grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' | head -1 | awk '{print $2}')
|
||||
r_port=$(ps -Ao command \
|
||||
| grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/opencode-router" \
|
||||
| grep -v grep \
|
||||
| grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' \
|
||||
| head -1 \
|
||||
| awk '{print $2}' \
|
||||
|| true)
|
||||
if [[ -z "$r_port" ]]; then
|
||||
echo " (no opencode-router info)"
|
||||
else
|
||||
@@ -178,6 +287,148 @@ kill_orphans() {
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
}
|
||||
|
||||
diagnose_hang() {
|
||||
local now
|
||||
now=$(date "+%Y-%m-%dT%H:%M:%S%z")
|
||||
echo "=== openwork hang diagnosis ==="
|
||||
echo "time=$now"
|
||||
echo "repo=$REPO_ROOT"
|
||||
echo "cdp=http://127.0.0.1:$ELECTRON_CDP_PORT"
|
||||
echo
|
||||
|
||||
echo "=== browser CDP ==="
|
||||
local browser_json target_json
|
||||
browser_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/version" 2>&1 || true)
|
||||
if [[ "$browser_json" == *"webSocketDebuggerUrl"* ]]; then
|
||||
echo " browser: responsive"
|
||||
printf '%s\n' "$browser_json" | sed -n '1,8p'
|
||||
else
|
||||
echo " browser: not reachable"
|
||||
printf ' %s\n' "$browser_json"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== page target ==="
|
||||
target_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/list" 2>&1 || true)
|
||||
if [[ "$target_json" == *"webSocketDebuggerUrl"* ]]; then
|
||||
printf '%s\n' "$target_json" | sed -n '1,20p'
|
||||
else
|
||||
echo " no page target (or target list failed)"
|
||||
printf ' %s\n' "$target_json"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== renderer process ==="
|
||||
local renderer_stats renderer_pid renderer_cpu
|
||||
renderer_stats=$(electron_renderer_stats)
|
||||
renderer_pid=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $1}')
|
||||
renderer_cpu=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $3}')
|
||||
if [[ -n "$renderer_pid" ]]; then
|
||||
echo " renderer: alive"
|
||||
echo " PID PPID %CPU %MEM RSS COMMAND"
|
||||
printf ' %s\n' "$renderer_stats" | sed -E 's#/Users/[^ ]*/#…/#g'
|
||||
else
|
||||
echo " renderer: missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== page CDP probe ==="
|
||||
local page_probe="skipped"
|
||||
if [[ "$browser_json" == *"webSocketDebuggerUrl"* && "$target_json" == *"webSocketDebuggerUrl"* ]]; then
|
||||
if page_probe=$(OPENWORK_ELECTRON_REMOTE_DEBUG_PORT="$ELECTRON_CDP_PORT" probe_electron_page_cdp 2>&1); then
|
||||
echo " page: responsive ($page_probe)"
|
||||
page_probe="ok"
|
||||
else
|
||||
echo " page: unresponsive ($page_probe)"
|
||||
page_probe="failed"
|
||||
fi
|
||||
else
|
||||
echo " page: skipped (browser or page target unavailable)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== classification ==="
|
||||
local high_cpu="false"
|
||||
if [[ -n "${renderer_cpu:-}" ]]; then
|
||||
high_cpu=$(awk -v cpu="$renderer_cpu" 'BEGIN {print (cpu + 0 >= 80) ? "true" : "false"}')
|
||||
fi
|
||||
if [[ "$browser_json" != *"webSocketDebuggerUrl"* ]]; then
|
||||
echo " no-electron-cdp: Electron is down or was not launched with CDP."
|
||||
elif [[ "$target_json" == *"webSocketDebuggerUrl"* && -z "$renderer_pid" ]]; then
|
||||
echo " renderer-crashed: browser CDP advertises a page but no renderer process exists."
|
||||
echo " next: inspect latest macOS .ips crash report below."
|
||||
elif [[ -n "$renderer_pid" && "$page_probe" == "failed" && "$high_cpu" == "true" ]]; then
|
||||
echo " renderer-hung-hot: renderer exists, page CDP timed out, CPU >= 80%."
|
||||
echo " next: use the sample captured below; logs will usually stop once wedged."
|
||||
elif [[ -n "$renderer_pid" && "$page_probe" == "failed" ]]; then
|
||||
echo " renderer-unresponsive: renderer exists but page CDP timed out."
|
||||
echo " next: sample + check memory/CPU; could be blocked main thread or native stall."
|
||||
elif [[ -n "$renderer_pid" && "$page_probe" == "ok" ]]; then
|
||||
echo " renderer-responsive: likely app-state or sidecar/API issue, not a renderer hang."
|
||||
else
|
||||
echo " unknown: insufficient signal."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== latest crash report ==="
|
||||
local crash
|
||||
crash=$(ls -t "$HOME"/Library/Logs/DiagnosticReports/*"Electron Helper (Renderer)"*.ips 2>/dev/null | head -1 || true)
|
||||
if [[ -n "$crash" ]]; then
|
||||
ls -la "$crash"
|
||||
sed -n '1,55p' "$crash" 2>/dev/null | sed -n '1,25p'
|
||||
else
|
||||
echo " no Electron renderer .ips crash report found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== sidecar health ==="
|
||||
local port
|
||||
port=$(discover_openwork_server_port)
|
||||
if [[ -n "$port" ]]; then
|
||||
echo " openwork-server port=$port"
|
||||
curl -sS --max-time 2 "http://127.0.0.1:$port/health" || echo "unreachable"
|
||||
echo
|
||||
else
|
||||
echo " no openwork-server port discovered"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== recent dev sink ==="
|
||||
echo " path=$DEV_LOG_FILE"
|
||||
if [[ -f "$DEV_LOG_FILE" ]]; then
|
||||
ls -la "$DEV_LOG_FILE"
|
||||
tail -40 "$DEV_LOG_FILE"
|
||||
else
|
||||
echo " missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== recent pnpm log ==="
|
||||
echo " path=$PNPM_DEV_LOG"
|
||||
if [[ -f "$PNPM_DEV_LOG" ]]; then
|
||||
ls -la "$PNPM_DEV_LOG"
|
||||
tail -80 "$PNPM_DEV_LOG"
|
||||
else
|
||||
echo " missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== renderer sample ==="
|
||||
if [[ -n "$renderer_pid" && ( "$page_probe" == "failed" || "$high_cpu" == "true" ) ]]; then
|
||||
local sample_file="/tmp/openwork-renderer-${renderer_pid}-$(date +%Y%m%dT%H%M%S).sample.txt"
|
||||
if sample "$renderer_pid" 3 -file "$sample_file" >/dev/null 2>&1; then
|
||||
echo " captured=$sample_file"
|
||||
grep -n "Thread_.*CrRendererMain\|Call graph:\|Physical footprint" "$sample_file" | head -20 || true
|
||||
else
|
||||
echo " sample failed for pid=$renderer_pid"
|
||||
fi
|
||||
elif [[ -n "$renderer_pid" ]]; then
|
||||
echo " skipped (renderer responsive and CPU not high). Set OPENWORK_FORCE_SAMPLE=1 not currently supported."
|
||||
else
|
||||
echo " skipped (no renderer process to sample)."
|
||||
fi
|
||||
}
|
||||
|
||||
# Ordered teardown. Safe to run when nothing is up; each step is idempotent.
|
||||
stop() {
|
||||
log "stopping dev stack (layered)"
|
||||
@@ -195,10 +446,20 @@ stop() {
|
||||
kill_by_pattern "tauri(-cli)? +dev"
|
||||
kill_by_pattern "@tauri-apps/cli"
|
||||
|
||||
# 2b. Electron variant supervisor (scripts/electron-dev.mjs). Idempotent if
|
||||
# the current variant is tauri; harmless if nothing matches.
|
||||
kill_by_pattern "apps/desktop/scripts/electron-dev\.mjs"
|
||||
|
||||
# 3. Tauri dev webview — match full path so the installed /Applications/
|
||||
# prod bundle is never targeted.
|
||||
kill_by_pattern "target/debug/OpenWork-Dev"
|
||||
|
||||
# 3b. Electron main (the helpers die with the main via mach-port rendezvous
|
||||
# loss; we still pattern-match them below in case a crash left orphans).
|
||||
# Scoped to this repo's node_modules/electron so /Applications/Slack,
|
||||
# Cursor, VSCode, etc. are never touched.
|
||||
kill_by_pattern "node_modules/electron/dist/Electron\.app/Contents/MacOS/Electron"
|
||||
|
||||
# 4. Vite. Match the node process that loads the vite binary from this
|
||||
# repo's node_modules, not any arbitrary node process on the host.
|
||||
kill_by_pattern "node_modules/\.bin/vite"
|
||||
@@ -207,10 +468,16 @@ stop() {
|
||||
# 5. openwork-server / orchestrator / opencode / opencode-router for the
|
||||
# current dev build. These are the longest-lived children and the ones
|
||||
# most likely to orphan after an unclean shutdown.
|
||||
# Tauri dev runs them from target/debug/, Electron dev runs them from
|
||||
# src-tauri/sidecars/ — kill both trees, both are idempotent.
|
||||
kill_by_pattern "target/debug/openwork-server"
|
||||
kill_by_pattern "target/debug/openwork-orchestrator"
|
||||
kill_by_pattern "target/debug/opencode"
|
||||
kill_by_pattern "target/debug/opencode-router"
|
||||
kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-server"
|
||||
kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-orchestrator"
|
||||
kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode( |/)"
|
||||
kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode-router"
|
||||
|
||||
# Safety net for stragglers we don't own directly.
|
||||
kill_orphans
|
||||
@@ -233,11 +500,22 @@ start() {
|
||||
fi
|
||||
fi
|
||||
|
||||
log "starting pnpm dev (log sink: $DEV_LOG_FILE)"
|
||||
cd "$REPO_ROOT"
|
||||
env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
|
||||
nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 &
|
||||
local pid=$!
|
||||
local pid
|
||||
case "$DEV_VARIANT" in
|
||||
electron)
|
||||
log "starting pnpm dev:electron (variant=electron, log sink: $DEV_LOG_FILE, CDP: 127.0.0.1:9823)"
|
||||
env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
|
||||
nohup pnpm --filter @openwork/desktop dev:electron >"$PNPM_DEV_LOG" 2>&1 &
|
||||
pid=$!
|
||||
;;
|
||||
tauri)
|
||||
log "starting pnpm dev (variant=tauri, log sink: $DEV_LOG_FILE)"
|
||||
env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
|
||||
nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 &
|
||||
pid=$!
|
||||
;;
|
||||
esac
|
||||
disown "$pid" 2>/dev/null || true
|
||||
echo "$pid" >"$PNPM_DEV_PID_FILE"
|
||||
log "pnpm dev pid=$pid"
|
||||
@@ -282,8 +560,17 @@ reset() {
|
||||
echo
|
||||
snapshot | sed -n '1,20p'
|
||||
echo
|
||||
log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop"
|
||||
log "its in-memory module cache and pick up the fresh Vite."
|
||||
case "$DEV_VARIANT" in
|
||||
electron)
|
||||
log "reset complete (variant=electron) — Electron CDP should be up at"
|
||||
log "http://127.0.0.1:9823; chrome-devtools MCP can attach there."
|
||||
log "If the window looks stale, Cmd+Shift+R to drop its Vite module cache."
|
||||
;;
|
||||
tauri)
|
||||
log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop"
|
||||
log "its in-memory module cache and pick up the fresh Vite."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
reset_webview_state() {
|
||||
@@ -317,6 +604,9 @@ case "$cmd" in
|
||||
kill-orphans)
|
||||
kill_orphans
|
||||
;;
|
||||
diagnose-hang)
|
||||
diagnose_hang
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user