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
+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,