feat(goal-run): add multi-checkpoint goal orchestration with H5 and admin surfaces.

Persist goal runs in MySQL, bind agent runs to checkpoints, expose awaiting-approval
UX in chat, and add admin inspection routes with local verify scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 17:03:16 +08:00
parent 43bc8bbc2b
commit 666db0b939
47 changed files with 4417 additions and 4 deletions
+9
View File
@@ -42,12 +42,14 @@ function AuthenticatedApp({
user,
capabilities,
grantedSkills,
goalRunEnabled,
onUserUpdate,
onLogout,
}: {
user: PortalUser | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
goalRunEnabled?: boolean;
onUserUpdate: (user: PortalUser) => void;
onLogout: () => void;
}) {
@@ -98,6 +100,7 @@ function AuthenticatedApp({
user={user}
capabilities={capabilities}
grantedSkills={grantedSkills}
goalRunEnabled={goalRunEnabled}
onUserUpdate={onUserUpdate}
>
<Routes>
@@ -123,6 +126,7 @@ export function App() {
const [user, setUser] = useState<PortalUser | null>(null);
const [capabilities, setCapabilities] = useState<CapabilityMap | undefined>();
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [goalRunEnabled, setGoalRunEnabled] = useState<boolean | undefined>(undefined);
const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useProductAnalytics(user?.id);
@@ -148,6 +152,7 @@ export function App() {
setUser(status.user ?? null);
setCapabilities(status.capabilities);
setGrantedSkills(status.grantedSkills);
setGoalRunEnabled(status.goalRun?.enabled);
if (status.authenticated) void loadBlockedWords();
});
return () => setUnauthorizedHandler(null);
@@ -207,6 +212,9 @@ export function App() {
setUser(nextUser ?? null);
setCapabilities(nextCapabilities);
setGrantedSkills(nextSkills);
void checkAuth().then((status) => {
setGoalRunEnabled(status.goalRun?.enabled);
});
}}
/>
);
@@ -217,6 +225,7 @@ export function App() {
user={user}
capabilities={capabilities}
grantedSkills={grantedSkills}
goalRunEnabled={goalRunEnabled}
onUserUpdate={setUser}
onLogout={() => {
clearAllStoredSessionIds();
+1
View File
@@ -1508,6 +1508,7 @@ export async function createAgentRun(
...(options.selectedAssetIds?.length
? { selected_asset_ids: options.selectedAssetIds }
: {}),
...(options.goalRunId ? { goal_run_id: options.goalRunId } : {}),
}),
},
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
+77
View File
@@ -0,0 +1,77 @@
import type { GoalRun } from '../types';
import type { AgentRun } from './client';
import { apiFetch, ApiError } from './core';
export type GoalRunAwaitingItem = {
goal: GoalRun;
checkpoint: GoalRun['checkpoints'][number];
};
export function findAwaitingApprovalCheckpoint(goal: GoalRun) {
return goal.checkpoints.find((item) => item.status === 'awaiting_approval') ?? null;
}
export function listAwaitingGoalRuns(goals: GoalRun[]): GoalRunAwaitingItem[] {
return goals
.map((goal) => {
const checkpoint = findAwaitingApprovalCheckpoint(goal);
return checkpoint ? { goal, checkpoint } : null;
})
.filter((item): item is GoalRunAwaitingItem => item != null);
}
export async function listGoalRuns(options?: {
statuses?: string[];
limit?: number;
}): Promise<GoalRun[]> {
const params = new URLSearchParams();
if (options?.statuses?.length) {
params.set('status', options.statuses.join(','));
}
if (options?.limit != null) {
params.set('limit', String(options.limit));
}
const qs = params.size ? `?${params.toString()}` : '';
const result = await apiFetch<{ goals: GoalRun[] }>(`/goals${qs}`);
return result.goals ?? [];
}
export async function getGoalRun(goalRunId: string): Promise<GoalRun | null> {
try {
const result = await apiFetch<{ goal: GoalRun }>(`/goals/${encodeURIComponent(goalRunId)}`);
return result.goal ?? null;
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export async function approveGoalCheckpoint(
goalRunId: string,
checkpointId: string,
feedback?: string | null,
sessionId?: string | null,
): Promise<{ goal: GoalRun; run?: AgentRun | null }> {
const result = await apiFetch<{ goal: GoalRun; run?: AgentRun | null }>(
`/goals/${encodeURIComponent(goalRunId)}/checkpoints/${encodeURIComponent(checkpointId)}/approve`,
{
method: 'POST',
body: JSON.stringify({
feedback: feedback ?? null,
...(sessionId ? { session_id: sessionId } : {}),
}),
},
);
return { goal: result.goal, run: result.run ?? null };
}
export async function cancelGoalRun(goalRunId: string): Promise<GoalRun> {
const result = await apiFetch<{ goal: GoalRun }>(`/goals/${encodeURIComponent(goalRunId)}`, {
method: 'DELETE',
});
return result.goal;
}
export function isGoalRunApiAvailableError(err: unknown): boolean {
return err instanceof ApiError && (err.status === 403 || err.status === 503);
}
+22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
@@ -15,6 +16,7 @@ import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import { GoalRunAwaitingBanner } from './GoalRunAwaitingBanner';
import type { MindSpaceSaveCategory } from '../types';
function RecentSessionRail({
@@ -212,8 +214,18 @@ export function ChatView({
openSubscribe,
uploadChatImage,
uploadChatAttachment,
followAgentRun,
goalRunEnabled,
} = useChat();
const online = useNetworkStatus();
const goalRunBanner = useGoalRunBanner({
userId: user?.id,
sessionId: session?.id,
chatState,
submit,
followAgentRun,
featureEnabled: goalRunEnabled,
});
const [sidebarOpen, setSidebarOpen] = useState(false);
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
@@ -487,6 +499,16 @@ export function ChatView({
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
{goalRunBanner.enabled && (
<GoalRunAwaitingBanner
items={goalRunBanner.items}
busy={goalRunBanner.bannerBusy}
onContinue={goalRunBanner.handleContinue}
onDismiss={goalRunBanner.dismissItem}
onRefresh={goalRunBanner.refresh}
/>
)}
{typeof balanceCents === 'number' && balanceCents <= 0 && (
<div className="banner banner-error">
<span>使</span>
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { cancelGoalRun, type GoalRunAwaitingItem } from '../api/goalRun';
type GoalRunAwaitingBannerProps = {
items: GoalRunAwaitingItem[];
busy: boolean;
onContinue: (input: {
goalRunId: string;
checkpointId: string;
feedback?: string | null;
}) => Promise<void>;
onDismiss: (goalRunId: string) => void;
onRefresh: () => Promise<void>;
};
function GoalRunAwaitingCard({
item,
busy,
onContinue,
onDismiss,
onRefresh,
}: {
item: GoalRunAwaitingItem;
busy: boolean;
onContinue: GoalRunAwaitingBannerProps['onContinue'];
onDismiss: (goalRunId: string) => void;
onRefresh: () => Promise<void>;
}) {
const [feedback, setFeedback] = useState('');
const [action, setAction] = useState<'continue' | 'cancel' | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const { goal, checkpoint } = item;
const summary = checkpoint.outputSummary?.trim();
const handleContinue = async () => {
setLocalError(null);
setAction('continue');
try {
await onContinue({
goalRunId: goal.id,
checkpointId: checkpoint.id,
feedback: feedback.trim() || null,
});
onDismiss(goal.id);
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
await onRefresh();
} finally {
setAction(null);
}
};
const handleCancel = async () => {
setLocalError(null);
setAction('cancel');
try {
await cancelGoalRun(goal.id);
onDismiss(goal.id);
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
await onRefresh();
} finally {
setAction(null);
}
};
const cardBusy = busy || action != null;
return (
<div className="banner banner-info goal-run-awaiting-banner">
<div className="goal-run-awaiting-banner-body">
<strong>{goal.title}</strong>
<span>
{checkpoint.title}
</span>
{summary ? <span className="goal-run-awaiting-summary">{summary}</span> : null}
<textarea
className="goal-run-awaiting-feedback"
rows={2}
placeholder="可选:补充反馈或修改方向"
value={feedback}
disabled={cardBusy}
onChange={(event) => setFeedback(event.target.value)}
/>
{localError ? <span className="goal-run-awaiting-error">{localError}</span> : null}
</div>
<div className="goal-run-awaiting-actions">
<button
type="button"
className="banner-action"
disabled={cardBusy}
onClick={() => void handleContinue()}
>
{action === 'continue' ? '处理中…' : '确认并继续'}
</button>
<button
type="button"
className="banner-dismiss"
disabled={cardBusy}
onClick={() => void handleCancel()}
>
{action === 'cancel' ? '取消中…' : '取消目标'}
</button>
</div>
</div>
);
}
export function GoalRunAwaitingBanner({
items,
busy,
onContinue,
onDismiss,
onRefresh,
}: GoalRunAwaitingBannerProps) {
if (!items.length) return null;
return (
<div className="goal-run-awaiting-stack">
{items.map((item) => (
<GoalRunAwaitingCard
key={item.goal.id}
item={item}
busy={busy}
onContinue={onContinue}
onDismiss={onDismiss}
onRefresh={onRefresh}
/>
))}
</div>
);
}
+3 -1
View File
@@ -12,19 +12,21 @@ export function ChatProvider({
user,
capabilities,
grantedSkills,
goalRunEnabled,
onUserUpdate,
children,
}: {
user?: PortalUser | null;
capabilities?: CapabilityMap | null;
grantedSkills?: string[];
goalRunEnabled?: boolean;
onUserUpdate?: (user: PortalUser) => void;
children: ReactNode;
}) {
const chat = useTKMindChat(user, onUserUpdate, capabilities, grantedSkills);
return (
<ChatContext.Provider value={chat}>
<ChatContext.Provider value={{ ...chat, goalRunEnabled }}>
{children}
{typeof chat.balanceCents === 'number' && chat.rechargePrompt && (
<RechargeModal
+94
View File
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
isGoalRunApiAvailableError,
listAwaitingGoalRuns,
listGoalRuns,
type GoalRunAwaitingItem,
} from '../api/goalRun';
import type { ChatState } from '../types';
export function useGoalRunAwaiting({
userId,
sessionId,
chatState,
featureEnabled,
}: {
userId?: string | null;
sessionId?: string | null;
chatState: ChatState;
featureEnabled?: boolean;
}) {
const [enabled, setEnabled] = useState(false);
const [items, setItems] = useState<GoalRunAwaitingItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const unavailableRef = useRef(featureEnabled === false);
const refresh = useCallback(async () => {
if (!userId || unavailableRef.current) return;
if (featureEnabled === false) {
unavailableRef.current = true;
setEnabled(false);
setItems([]);
return;
}
setLoading(true);
setError(null);
try {
const goals = await listGoalRuns({
statuses: ['awaiting_user'],
limit: 20,
});
setEnabled(true);
const awaiting = listAwaitingGoalRuns(goals);
if (sessionId) {
awaiting.sort((left, right) => {
const leftMatch = left.goal.sourceSessionId === sessionId ? 1 : 0;
const rightMatch = right.goal.sourceSessionId === sessionId ? 1 : 0;
return rightMatch - leftMatch;
});
}
setItems(awaiting);
} catch (err) {
if (isGoalRunApiAvailableError(err)) {
unavailableRef.current = true;
setEnabled(false);
setItems([]);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [featureEnabled, sessionId, userId]);
useEffect(() => {
unavailableRef.current = featureEnabled === false;
if (!userId || featureEnabled === false) {
setEnabled(false);
setItems([]);
return;
}
void refresh();
}, [featureEnabled, refresh, userId]);
useEffect(() => {
if (!userId || unavailableRef.current) return;
if (chatState === 'idle' || chatState === 'error') {
void refresh();
}
}, [chatState, refresh, userId]);
const dismissItem = useCallback((goalRunId: string) => {
setItems((prev) => prev.filter((item) => item.goal.id !== goalRunId));
}, []);
return {
enabled,
items,
loading,
error,
refresh,
dismissItem,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback } from 'react';
import type { AgentRun } from '../api/client';
import { approveGoalCheckpoint } from '../api/goalRun';
import type { ChatState } from '../types';
import { useGoalRunAwaiting } from './useGoalRunAwaiting';
export function useGoalRunBanner({
userId,
sessionId,
chatState,
submit,
followAgentRun,
featureEnabled,
}: {
userId?: string | null;
sessionId?: string | null;
chatState: ChatState;
submit: (
text: string,
options?: { goalRunId?: string },
) => Promise<void>;
followAgentRun?: (run: AgentRun) => Promise<void>;
featureEnabled?: boolean;
}) {
const awaiting = useGoalRunAwaiting({ userId, sessionId, chatState, featureEnabled });
const handleContinue = useCallback(
async ({
goalRunId,
checkpointId,
feedback,
}: {
goalRunId: string;
checkpointId: string;
feedback?: string | null;
}) => {
const result = await approveGoalCheckpoint(
goalRunId,
checkpointId,
feedback,
sessionId,
);
if (result.run && followAgentRun) {
await followAgentRun(result.run);
return;
}
const continueText = feedback?.trim()
? `继续下一阶段。补充说明:${feedback.trim()}`
: '继续执行下一阶段';
await submit(continueText, { goalRunId });
},
[followAgentRun, sessionId, submit],
);
const bannerBusy =
chatState === 'waiting' ||
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting';
return {
...awaiting,
handleContinue,
bannerBusy,
};
}
+85 -1
View File
@@ -1041,7 +1041,7 @@ export function useTKMindChat(
return;
}
},
[clearActiveRequestMissingTimer, rememberRecentContext, syncSessionMessages],
[canUseLongTermMemory, clearActiveRequestMissingTimer, rememberRecentContext, syncSessionMessages],
);
const subscribeToSession = useCallback(
@@ -1473,6 +1473,7 @@ export function useTKMindChat(
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
goalRunId?: string;
},
imageUrls?: string[],
previewImageUrls?: string[],
@@ -1571,6 +1572,7 @@ export function useTKMindChat(
.filter(Boolean),
}
: {}),
...(options?.goalRunId ? { goalRunId: options.goalRunId } : {}),
},
);
const runSessionId = createdRun.sessionId ?? activeSessionId;
@@ -1774,6 +1776,87 @@ export function useTKMindChat(
[session, pendingTool],
);
const followAgentRun = useCallback(
async (createdRun: AgentRun) => {
if (
chatStateRef.current === 'streaming' ||
chatStateRef.current === 'loading' ||
chatStateRef.current === 'connecting' ||
chatStateRef.current === 'waiting'
) {
return;
}
const submitToken = connectTokenRef.current;
let activeSessionId = sessionRef.current?.id ?? createdRun.sessionId ?? null;
if (createdRun.requestId) {
activeRequestId.current = createdRun.requestId;
}
setChatState('waiting');
chatStateRef.current = 'waiting';
setError(null);
agentRunPendingRef.current = true;
try {
const finishedRun =
createdRun.status === 'succeeded'
? createdRun
: await waitForAgentRunWithDirectChatPreview(createdRun.id, {
isCancelled: () => submitToken !== connectTokenRef.current,
onSessionId: (sessionId) => {
if (submitToken !== connectTokenRef.current) return;
if (activeSessionId !== sessionId) {
activeSessionId = sessionId;
const nextSession: Session = {
id: sessionId,
name: 'New Chat',
message_count: messagesRef.current.length,
working_dir: '',
};
writeStoredSessionId(userRef.current?.id, sessionId);
setSession(nextSession);
setSessions((prev) => prependUnique(prev, nextSession));
}
if (!isDirectChatSessionId(sessionId)) {
subscribeToSession(sessionId);
if (shouldPromoteSessionIdToStreaming(chatStateRef.current)) {
setChatState('streaming');
}
}
},
onMessages: (snapshotMessages) => {
if (submitToken !== connectTokenRef.current) return;
messagesRef.current = mergeConversationSnapshot(
messagesRef.current,
snapshotMessages,
);
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
},
});
if (submitToken !== connectTokenRef.current) return;
agentRunPendingRef.current = false;
activeSessionId = finishedRun.sessionId ?? activeSessionId;
if (!activeSessionId) {
clearActiveRequestMissingTimer();
activeRequestId.current = null;
setChatState('idle');
return;
}
subscribeToSession(activeSessionId);
setChatState('streaming');
scheduleReplyRecoverySync(activeSessionId, submitToken);
} catch (err) {
agentRunPendingRef.current = false;
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
clearActiveRequestMissingTimer();
activeRequestId.current = null;
}
},
[clearActiveRequestMissingTimer, scheduleReplyRecoverySync, subscribeToSession],
);
const newSession = useCallback(async () => {
const token = ++connectTokenRef.current;
const previousSession = sessionRef.current;
@@ -1974,6 +2057,7 @@ export function useTKMindChat(
grantedSkills,
submit,
stop,
followAgentRun,
approveTool,
newSession,
rememberCurrentContext,
+39
View File
@@ -181,6 +181,40 @@ export type UserMemorySyncResponse = {
syncedToSession: boolean;
};
export type GoalRunCheckpoint = {
id: string;
goalRunId: string;
sequence: number;
title: string;
description: string | null;
status: string;
agentRunId: string | null;
outputSummary: string | null;
userFeedback: string | null;
approvedAt: number | null;
createdAt: number;
updatedAt: number;
startedAt: number | null;
completedAt: number | null;
};
export type GoalRun = {
id: string;
userId: string;
title: string;
intentSummary: string;
status: string;
priority: number;
sourceChannel: string;
sourceSessionId: string | null;
sourceMessageId: string | null;
currentCheckpointId: string | null;
checkpoints: GoalRunCheckpoint[];
createdAt: number;
updatedAt: number;
completedAt: number | null;
};
export type ChatState = 'idle' | 'loading' | 'connecting' | 'streaming' | 'waiting' | 'error';
export type ToolConfirmation = {
@@ -913,6 +947,10 @@ export type AgentCodeRunClientPolicy = {
};
};
export type GoalRunClientPolicy = {
enabled?: boolean;
};
export type AuthStatus = {
authenticated: boolean;
mode?: 'user' | 'legacy' | 'none' | 'unavailable';
@@ -922,6 +960,7 @@ export type AuthStatus = {
grantedSkills?: string[];
unrestricted?: boolean;
agentCodeRun?: AgentCodeRunClientPolicy | null;
goalRun?: GoalRunClientPolicy | null;
};
export type PathGrant = {
+1
View File
@@ -8,6 +8,7 @@ export type AgentRunCreateOptions = {
validation?: AgentRunValidation | null;
validationInstruction?: string | null;
selectedAssetIds?: string[];
goalRunId?: string | null;
};
export type AgentRunValidationFile = {