From 666db0b939ea95fde59378e11d842051c0279b0d Mon Sep 17 00:00:00 2001 From: john Date: Sat, 1 Aug 2026 17:03:16 +0800 Subject: [PATCH] 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 --- admin-bootstrap.mjs | 3 + admin-routes.mjs | 101 +++ admin-routes.test.mjs | 147 +++++ admin-server.mjs | 1 + agent-run-gateway.mjs | 48 +- agent-run-gateway.test.mjs | 90 +++ agent-run-routes.mjs | 25 + agent-run-routes.test.mjs | 49 ++ db.mjs | 6 + goal-run-admin-ops.mjs | 207 ++++++ goal-run-admin-ops.test.mjs | 121 ++++ goal-run-awaiting.mjs | 14 + goal-run-awaiting.test.mjs | 30 + goal-run-context.mjs | 42 ++ goal-run-gateway.test.mjs | 122 ++++ goal-run-policy.mjs | 14 + goal-run-policy.test.mjs | 33 + goal-run-resolve.mjs | 67 ++ goal-run-resolve.test.mjs | 109 +++ goal-run-service.mjs | 661 +++++++++++++++++++ goal-run-service.test.mjs | 334 ++++++++++ ops/src/App.tsx | 2 + ops/src/api/admin.ts | 123 ++++ ops/src/components/AdminLayout.tsx | 1 + ops/src/pages/admin/GoalRunPage.tsx | 390 +++++++++++ ops/src/pages/admin/SummaryPage.tsx | 3 + package.json | 3 + scripts/goal-run-verify-lib.mjs | 37 ++ scripts/verify-goal-run-http-local.mjs | 363 ++++++++++ scripts/verify-goal-run-local.mjs | 246 +++++++ server.mjs | 18 + server/portal-core-auth-routes.mjs | 6 + server/portal-core-auth-routes.test.mjs | 5 + server/portal-gateway-services-bootstrap.mjs | 4 + server/portal-goal-run-routes.mjs | 239 +++++++ server/portal-goal-run-routes.test.mjs | 226 +++++++ src/App.tsx | 9 + src/api/client.ts | 1 + src/api/goalRun.ts | 77 +++ src/components/ChatView.tsx | 22 + src/components/GoalRunAwaitingBanner.tsx | 132 ++++ src/context/ChatProvider.tsx | 4 +- src/hooks/useGoalRunAwaiting.ts | 94 +++ src/hooks/useGoalRunBanner.ts | 66 ++ src/hooks/useTKMindChat.ts | 86 ++- src/types.ts | 39 ++ src/utils/agentRunMode.ts | 1 + 47 files changed, 4417 insertions(+), 4 deletions(-) create mode 100644 goal-run-admin-ops.mjs create mode 100644 goal-run-admin-ops.test.mjs create mode 100644 goal-run-awaiting.mjs create mode 100644 goal-run-awaiting.test.mjs create mode 100644 goal-run-context.mjs create mode 100644 goal-run-gateway.test.mjs create mode 100644 goal-run-policy.mjs create mode 100644 goal-run-policy.test.mjs create mode 100644 goal-run-resolve.mjs create mode 100644 goal-run-resolve.test.mjs create mode 100644 goal-run-service.mjs create mode 100644 goal-run-service.test.mjs create mode 100644 ops/src/pages/admin/GoalRunPage.tsx create mode 100644 scripts/goal-run-verify-lib.mjs create mode 100644 scripts/verify-goal-run-http-local.mjs create mode 100644 scripts/verify-goal-run-local.mjs create mode 100644 server/portal-goal-run-routes.mjs create mode 100644 server/portal-goal-run-routes.test.mjs create mode 100644 src/api/goalRun.ts create mode 100644 src/components/GoalRunAwaitingBanner.tsx create mode 100644 src/hooks/useGoalRunAwaiting.ts create mode 100644 src/hooks/useGoalRunBanner.ts diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 5f6803e..f0b28ca 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs'; import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; +import { createGoalRunAdminOpsService } from './goal-run-admin-ops.mjs'; import { createOrchestratorAdminConfigService } from './services/orchestrator/admin-config.mjs'; import { createOrchestratorObservabilityService } from './services/orchestrator/observability.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; @@ -112,6 +113,7 @@ export async function createAdminServices(env = {}) { }); await imageMakeAdminConfigService.ensureSchema(); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const goalRunAdminOpsService = createGoalRunAdminOpsService(pool, { env: process.env }); const orchestratorConfigService = createOrchestratorAdminConfigService(pool); await orchestratorConfigService.ensureSchema(); const orchestratorObservabilityService = createOrchestratorObservabilityService({ @@ -165,6 +167,7 @@ export async function createAdminServices(env = {}) { assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + goalRunAdminOpsService, orchestratorConfigService, orchestratorObservabilityService, mindSearchConfigService, diff --git a/admin-routes.mjs b/admin-routes.mjs index 42ceead..b3d2529 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) { * @param {object} deps.userAuth * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService + * @param {object|null} deps.goalRunAdminOpsService * @param {object|null} deps.orchestratorConfigService * @param {object|null} deps.orchestratorObservabilityService * @param {object|null} deps.skillRuntimeConfigService @@ -50,6 +51,7 @@ export function createAdminApi({ assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + goalRunAdminOpsService, orchestratorConfigService, orchestratorObservabilityService, mindSearchConfigService, @@ -231,6 +233,105 @@ export function createAdminApi({ return res.json(result); }); + adminApi.get('/goal-runs/runtime', requireAdmin, async (_req, res) => { + if (!goalRunAdminOpsService?.getRuntime) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + return res.json(goalRunAdminOpsService.getRuntime()); + }); + + adminApi.get('/goal-runs/summary', requireAdmin, async (_req, res) => { + if (!goalRunAdminOpsService?.countByStatus) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + try { + const counts = await goalRunAdminOpsService.countByStatus(); + const runtime = goalRunAdminOpsService.getRuntime(); + return res.json({ counts, runtime }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取 Goal Run 汇总失败', + }); + } + }); + + adminApi.get('/goal-runs', requireAdmin, async (req, res) => { + if (!goalRunAdminOpsService?.listGoals) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + try { + const status = String(req.query?.status ?? '').trim() || null; + const userId = String(req.query?.userId ?? req.query?.user_id ?? '').trim() || null; + const limit = Number(req.query?.limit ?? 50); + const offset = Number(req.query?.offset ?? 0); + const items = await goalRunAdminOpsService.listGoals({ status, userId, limit, offset }); + return res.json({ items, status, limit, offset }); + } catch (err) { + return res.status(400).json({ + message: err instanceof Error ? err.message : '读取 Goal Run 列表失败', + }); + } + }); + + adminApi.get('/goal-runs/:goalRunId', requireAdmin, async (req, res) => { + if (!goalRunAdminOpsService?.getGoalDetail) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + try { + const goal = await goalRunAdminOpsService.getGoalDetail(req.params.goalRunId); + if (!goal) return res.status(404).json({ message: '目标不存在' }); + return res.json({ goal }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取 Goal Run 详情失败', + }); + } + }); + + adminApi.post('/goal-runs/:goalRunId/checkpoints/:checkpointId/approve', requireAdmin, async (req, res) => { + if (!goalRunAdminOpsService?.approveCheckpoint) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + try { + const goal = await goalRunAdminOpsService.approveCheckpoint({ + goalRunId: req.params.goalRunId, + checkpointId: req.params.checkpointId, + feedback: req.body?.feedback ?? null, + reviewedBy: req.currentUser.id, + }); + return res.json({ ok: true, goal }); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_FOUND' || err?.code === 'GOAL_CHECKPOINT_NOT_FOUND' + ? 404 + : err?.code === 'GOAL_CHECKPOINT_NOT_APPROVABLE' + ? 409 + : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '确认阶段失败', + code: err?.code ?? null, + }); + } + }); + + adminApi.post('/goal-runs/:goalRunId/cancel', requireAdmin, async (req, res) => { + if (!goalRunAdminOpsService?.cancelGoal) { + return res.status(503).json({ message: 'Goal Run 管理服务未启用' }); + } + try { + const goal = await goalRunAdminOpsService.cancelGoal({ + goalRunId: req.params.goalRunId, + reviewedBy: req.currentUser.id, + }); + return res.json({ ok: true, goal }); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_FOUND' ? 404 : err?.code === 'GOAL_RUN_NOT_CANCELLABLE' ? 409 : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '取消目标失败', + code: err?.code ?? null, + }); + } + }); + adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => { if (!orchestratorConfigService?.getAdminConfig) { return res.status(503).json({ message: 'Orchestrator 配置服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index bd84f0f..7a47fd8 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -90,6 +90,153 @@ test('admin memory-v2 config routes expose config and runtime state', async () = } }); +test('admin goal-runs routes expose summary, list, and detail', async () => { + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { + return 'token-admin'; + }, + userAuth: { + async getMe(token) { + if (token !== 'token-admin') return null; + return { id: 'admin-1', role: 'admin' }; + }, + }, + goalRunAdminOpsService: { + getRuntime() { + return { enabled: true, canaryUserIds: ['user-1'], canaryMode: true }; + }, + async countByStatus() { + return { active: 2, completed: 1 }; + }, + async listGoals() { + return [{ + id: 'goal-1', + userId: 'user-1', + username: 'john2', + title: '产品规划', + intentSummary: '分阶段完成', + status: 'active', + priority: 5, + sourceChannel: 'h5', + sourceSessionId: null, + currentCheckpointId: 'cp-1', + checkpointCount: 2, + activeAgentRunCount: 1, + createdAt: 1000, + updatedAt: 2000, + completedAt: null, + }]; + }, + async getGoalDetail(goalRunId) { + if (goalRunId !== 'goal-1') return null; + return { + id: 'goal-1', + userId: 'user-1', + username: 'john2', + title: '产品规划', + intentSummary: '分阶段完成', + status: 'active', + priority: 5, + sourceChannel: 'h5', + sourceSessionId: null, + sourceMessageId: null, + currentCheckpointId: 'cp-1', + context: null, + memorySnapshot: null, + createdAt: 1000, + updatedAt: 2000, + completedAt: null, + checkpoints: [{ + id: 'cp-1', + goalRunId: 'goal-1', + sequence: 1, + title: '调研', + description: null, + status: 'running', + agentRunId: 'run-1', + outputSummary: null, + userFeedback: null, + approvedAt: null, + createdAt: 1000, + updatedAt: 2000, + startedAt: 1500, + completedAt: null, + }], + agentRuns: [{ + id: 'run-1', + status: 'running', + requestId: 'req-1', + goalCheckpointId: 'cp-1', + createdAt: 1000, + updatedAt: 2000, + completedAt: null, + }], + canaryEnabled: true, + }; + }, + async approveCheckpoint({ goalRunId, checkpointId, feedback, reviewedBy }) { + assert.equal(goalRunId, 'goal-1'); + assert.equal(checkpointId, 'cp-1'); + assert.equal(feedback, 'looks good'); + assert.equal(reviewedBy, 'admin-1'); + return { id: 'goal-1', status: 'active', checkpoints: [] }; + }, + async cancelGoal({ goalRunId, reviewedBy }) { + assert.equal(goalRunId, 'goal-1'); + assert.equal(reviewedBy, 'admin-1'); + return { id: 'goal-1', status: 'cancelled', checkpoints: [] }; + }, + }, + }); + const server = await startTestServer(router); + try { + const summaryRes = await fetch(`${server.baseUrl}/admin-api/goal-runs/summary`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(summaryRes.status, 200); + const summaryBody = await summaryRes.json(); + assert.equal(summaryBody.counts.active, 2); + assert.equal(summaryBody.runtime.enabled, true); + + const listRes = await fetch(`${server.baseUrl}/admin-api/goal-runs`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(listRes.status, 200); + const listBody = await listRes.json(); + assert.equal(listBody.items[0].id, 'goal-1'); + + const detailRes = await fetch(`${server.baseUrl}/admin-api/goal-runs/goal-1`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(detailRes.status, 200); + const detailBody = await detailRes.json(); + assert.equal(detailBody.goal.checkpoints.length, 1); + + const approveRes = await fetch(`${server.baseUrl}/admin-api/goal-runs/goal-1/checkpoints/cp-1/approve`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ feedback: 'looks good' }), + }); + assert.equal(approveRes.status, 200); + + const cancelRes = await fetch(`${server.baseUrl}/admin-api/goal-runs/goal-1/cancel`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({}), + }); + assert.equal(cancelRes.status, 200); + } finally { + await server.close(); + } +}); + test('admin orchestrator routes expose a versioned plug-in control plane', async () => { const updates = []; const state = { diff --git a/admin-server.mjs b/admin-server.mjs index e3e4320..158cf96 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -79,6 +79,7 @@ const CONSOLES = { assetGatewayConfigService: services.assetGatewayConfigService, imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, + goalRunAdminOpsService: services.goalRunAdminOpsService, orchestratorConfigService: services.orchestratorConfigService, orchestratorObservabilityService: services.orchestratorObservabilityService, mindSearchConfigService: services.mindSearchConfigService, diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index fb50864..da9ce4b 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -620,6 +620,7 @@ export function createAgentRunGateway({ process.env.MEMIND_AGENT_SESSION_COMPACT_CHAR_COUNT, 120_000, ), + goalRunService = null, workerIdentity = null, }) { const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {}); @@ -1025,6 +1026,8 @@ export function createAgentRunGateway({ toolMode = 'chat', taskType = null, forceDeepReasoning = false, + goalRunId = null, + goalCheckpointId = null, }) { const normalizedRequestId = String(requestId ?? '').trim(); if (!normalizedRequestId) { @@ -1088,16 +1091,18 @@ export function createAgentRunGateway({ }); await pool.query( `INSERT INTO h5_agent_runs - (id, user_id, agent_session_id, request_id, status, attempts, + (id, user_id, agent_session_id, goal_run_id, goal_checkpoint_id, request_id, status, attempts, user_message_json, error_message, created_at, updated_at, started_at, completed_at, required_runtime_root, required_build_id, claimed_worker_id, claimed_runtime_root, claimed_build_id) - VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL, + VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL, ?, ?, NULL, NULL, NULL)`, [ runId, userId, sessionId || null, + goalRunId || null, + goalCheckpointId || null, normalizedRequestId, serializeMessage(runMessage), createdAt, @@ -1106,11 +1111,19 @@ export function createAgentRunGateway({ worker.buildId, ], ); + if (goalCheckpointId && goalRunService?.attachAgentRunToCheckpoint) { + await goalRunService.attachAgentRunToCheckpoint({ + checkpointId: goalCheckpointId, + agentRunId: runId, + }); + } await appendEvent(runId, 'queued', { sessionId: sessionId || null, toolMode: normalizedToolMode, taskType: normalizedTaskType, forceDeepReasoning: Boolean(forceDeepReasoning), + goalRunId: goalRunId || null, + goalCheckpointId: goalCheckpointId || null, requiredRuntimeRoot: worker.runtimeRoot, requiredBuildId: worker.buildId, }); @@ -1470,9 +1483,28 @@ export function createAgentRunGateway({ await appendEvent(runId, 'intent_routed', routing); if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) { const grantedSkills = await resolveGrantedSkills(row.user_id); + let goalContext = null; + if (goalRunService && row.goal_run_id) { + try { + const goal = await goalRunService.getGoalRun({ + userId: row.user_id, + goalRunId: row.goal_run_id, + }); + const envelope = goalRunService.buildGoalContextEnvelope(goal); + if (envelope) { + goalContext = { injectionEnabled: true, envelope }; + } + } catch (err) { + console.warn( + '[AgentRun] goal context envelope failed open:', + err instanceof Error ? err.message : err, + ); + } + } userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { grantedSkills, memoryContext: agentMemoryContext, + goalContext, }); } } @@ -2203,6 +2235,18 @@ export function createAgentRunGateway({ stopHeartbeat(); const terminalRun = await getRunById(runId).catch(() => null); if (terminalRun && TERMINAL_STATUSES.has(terminalRun.status)) { + if (goalRunService?.onAgentRunCompleted && terminalRun.goal_checkpoint_id) { + await goalRunService.onAgentRunCompleted({ + agentRunId: runId, + status: terminalRun.status, + outputSummary: extractRunDisplayText(terminalRun)?.slice(0, 500) ?? null, + }).catch((err) => { + console.warn( + '[AgentRun] goal run completion callback failed:', + err instanceof Error ? err.message : err, + ); + }); + } await quiesceTerminalSession(runId, { userId: terminalRun.user_id ?? row.user_id, sessionId: terminalRun.agent_session_id ?? row.agent_session_id ?? null, diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index dd7479a..823effa 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -231,6 +231,8 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } id, userId, sessionId, + goalRunId, + goalCheckpointId, requestId, userMessageJson, createdAt, @@ -242,6 +244,8 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } id, user_id: userId, agent_session_id: sessionId, + goal_run_id: goalRunId, + goal_checkpoint_id: goalCheckpointId, request_id: requestId, status: 'queued', attempts: 0, @@ -2199,6 +2203,92 @@ test('agent run uses direct chat on regular agent sessions when llm routes direc assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed')); }); +test('agent run reuses portal session when direct chat fails on first turn', async () => { + const previousNormalized = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1'; + const pool = createFakePool(); + const startedSessions = []; + const submitted = []; + try { + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + startedSessions.push('called'); + return { id: '20260731_12' }; + }, + async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + return { + ok: true, + finishEvent: { type: 'Finish' }, + tokenState: { totalTokens: 8 }, + }; + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'direct_chat', + confidence: 0.95, + reason: '简单寒暄或连通性测试', + source: 'rule', + decision: { + mode: 'sse', + flags: [], + route: 'chat', + session_hint: 'new', + }, + }; + }, + }, + directChatService: { + canHandle({ routingDecision }) { + return routingDecision === 'direct_chat'; + }, + explainCanHandle({ routingDecision }) { + return { ok: routingDecision === 'direct_chat', reason: null }; + }, + async run() { + throw new Error('fetch failed'); + }, + getStatus() { + return { enabled: true }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: '20260731_11', + requestId: 'req-direct-fallback-reuse', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + metadata: { + displayText: 'hi', + memindRun: { sessionMessageCount: 0 }, + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, '20260731_11'); + assert.equal(startedSessions.length, 0); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].sessionId, '20260731_11'); + assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_failed')); + assert.equal(pool.events.some((event) => event.eventType === 'session_started'), false); + } finally { + if (previousNormalized == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previousNormalized; + } +}); + test('agent run invalidates portal direct chat snapshot before submitting to goosed', async () => { const pool = createFakePool({ sessionDeliverables: { diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 3d65624..486d0ca 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -18,6 +18,7 @@ import { shouldEmitRunUpdateForStreamEvent, } from './agent-run-stream.mjs'; import { wrapRunStreamPayload, writeSseErrorAndEnd } from './sse-event-taxonomy.mjs'; +import { resolveGoalBindingForAgentRun } from './goal-run-resolve.mjs'; function envFlag(value) { return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); @@ -142,6 +143,9 @@ export function createPostAgentRunsHandler({ agentRunGateway, mindSpaceAssetAgent = null, codeRunPolicyService = null, + goalRunService = null, + chatIntentRouter = null, + env = process.env, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), @@ -293,6 +297,18 @@ export function createPostAgentRunsHandler({ return; } } + let goalBinding = null; + if (goalRunService) { + goalBinding = await resolveGoalBindingForAgentRun({ + goalRunService, + chatIntentRouter, + userId: request.currentUser.id, + userMessage, + sessionId, + body: request.body ?? {}, + env, + }); + } const run = await agentRunGateway.createRun(request.currentUser.id, { sessionId, requestId, @@ -300,6 +316,8 @@ export function createPostAgentRunsHandler({ toolMode, taskType, ...(forceDeepReasoning ? { forceDeepReasoning: true } : {}), + ...(goalBinding?.goalRunId ? { goalRunId: goalBinding.goalRunId } : {}), + ...(goalBinding?.goalCheckpointId ? { goalCheckpointId: goalBinding.goalCheckpointId } : {}), }); response.status(202).json({ run }); } catch (err) { @@ -310,6 +328,13 @@ export function createPostAgentRunsHandler({ }); return; } + if (err?.code === 'GOAL_RUN_NOT_FOUND') { + response.status(404).json({ + message: err.message, + code: 'GOAL_RUN_NOT_FOUND', + }); + return; + } response.status(500).json({ message: err instanceof Error ? err.message : '创建任务失败', }); diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index 86e9523..d982315 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -104,6 +104,55 @@ test('POST /agent/runs creates a run and returns 202', async () => { ]); }); +test('POST /agent/runs binds goal_run_id when goal intent resolves', async () => { + const created = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-goal-1', status: 'queued' }; + }, + }, + goalRunService: { + async createGoalRun() { + return { id: 'goal-1', checkpoints: [{ id: 'cp-1' }] }; + }, + async startNextCheckpoint() { + return { goalRunId: 'goal-1', checkpointId: 'cp-1' }; + }, + }, + chatIntentRouter: null, + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-1', + }, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-1', + request_id: 'req-goal-1', + user_message: { + role: 'user', + content: [{ type: 'text', text: '帮我分阶段完成下季度产品规划' }], + metadata: { displayText: '帮我分阶段完成下季度产品规划' }, + }, + }, + }, + res, + ); + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.goalRunId, 'goal-1'); + assert.equal(created[0].payload.goalCheckpointId, 'cp-1'); +}); + test('POST /agent/runs fails closed while the release drain marker exists', async () => { const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-release-drain-')); const marker = path.join(markerDir, 'drain'); diff --git a/db.mjs b/db.mjs index 603d709..f1a94ab 100644 --- a/db.mjs +++ b/db.mjs @@ -3,6 +3,7 @@ import mysql from 'mysql2/promise'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensureDefaultSpaces } from './mindspace.mjs'; +import { ensureGoalRunSchema } from './goal-run-service.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -416,6 +417,11 @@ export async function migrateSchema(pool) { ); } + await ensureGoalRunSchema(pool, { + columnExists: (table, column) => columnExists(pool, table, column), + indexExists: (table, index) => indexExists(pool, table, index), + }); + // MindSpace conversation packages: additive provenance tables for grouping // images, files, pages, and publications created during a chat session. await pool.query(` diff --git a/goal-run-admin-ops.mjs b/goal-run-admin-ops.mjs new file mode 100644 index 0000000..af98e22 --- /dev/null +++ b/goal-run-admin-ops.mjs @@ -0,0 +1,207 @@ +import { createGoalRunService, ensureGoalRunSchema } from './goal-run-service.mjs'; +import { isGoalRunEnabledForUser } from './goal-run-intent.mjs'; + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function parseUserIdList(raw) { + return String(raw ?? '') + .split(/[,;\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseJson(value, fallback = null) { + if (value == null) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(String(value)); + } catch { + return fallback; + } +} + +function normalizeGoalListRow(row) { + return { + id: String(row.id), + userId: String(row.user_id), + username: row.username == null ? null : String(row.username), + title: String(row.title), + intentSummary: String(row.intent_summary), + status: String(row.status), + priority: Number(row.priority ?? 5), + sourceChannel: String(row.source_channel ?? 'h5'), + sourceSessionId: row.source_session_id == null ? null : String(row.source_session_id), + currentCheckpointId: row.current_checkpoint_id == null ? null : String(row.current_checkpoint_id), + checkpointCount: Number(row.checkpoint_count ?? 0), + activeAgentRunCount: Number(row.active_agent_run_count ?? 0), + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + completedAt: row.completed_at == null ? null : Number(row.completed_at), + }; +} + +export function createGoalRunAdminOpsService(pool, { env = process.env, now = () => Date.now() } = {}) { + if (!pool?.query) return null; + const goalRunService = createGoalRunService({ pool, now }); + let schemaReady = false; + + async function ensureSchema() { + if (schemaReady) return; + await ensureGoalRunSchema(pool); + schemaReady = true; + } + + return { + getRuntime() { + const canaryUserIds = parseUserIdList(env.GOAL_RUN_CANARY_USER_IDS); + return { + enabled: envFlag(env.GOAL_RUN_ENABLED, false), + canaryUserIds, + canaryMode: canaryUserIds.length > 0, + }; + }, + + async countByStatus() { + await ensureSchema(); + const [rows] = await pool.query( + `SELECT status, COUNT(*) AS count + FROM h5_goal_runs + GROUP BY status`, + ); + const counts = {}; + for (const row of rows) { + counts[String(row.status)] = Number(row.count ?? 0); + } + return counts; + }, + + async listGoals({ + status = null, + userId = null, + limit = 50, + offset = 0, + } = {}) { + await ensureSchema(); + const safeLimit = Math.max(1, Math.min(100, Number(limit) || 50)); + const safeOffset = Math.max(0, Number(offset) || 0); + const filters = []; + const params = []; + if (status) { + filters.push('g.status = ?'); + params.push(String(status)); + } + if (userId) { + filters.push('g.user_id = ?'); + params.push(String(userId)); + } + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const [rows] = await pool.query( + `SELECT g.*, + u.username, + ( + SELECT COUNT(*) + FROM h5_goal_checkpoints c + WHERE c.goal_run_id = g.id + ) AS checkpoint_count, + ( + SELECT COUNT(*) + FROM h5_agent_runs r + WHERE r.goal_run_id = g.id + AND r.status NOT IN ('succeeded', 'failed') + ) AS active_agent_run_count + FROM h5_goal_runs g + LEFT JOIN h5_users u ON u.id = g.user_id + ${where} + ORDER BY g.updated_at DESC + LIMIT ? OFFSET ?`, + [...params, safeLimit, safeOffset], + ); + return rows.map(normalizeGoalListRow); + }, + + async getGoalDetail(goalRunId) { + await ensureSchema(); + const [rows] = await pool.query( + `SELECT g.*, u.username + FROM h5_goal_runs g + LEFT JOIN h5_users u ON u.id = g.user_id + WHERE g.id = ? + LIMIT 1`, + [String(goalRunId)], + ); + const row = rows[0]; + if (!row) return null; + const goal = await goalRunService.getGoalRun({ + userId: String(row.user_id), + goalRunId: String(goalRunId), + }); + if (!goal) return null; + const [agentRuns] = await pool.query( + `SELECT id, status, request_id, goal_checkpoint_id, created_at, updated_at, completed_at + FROM h5_agent_runs + WHERE goal_run_id = ? + ORDER BY created_at DESC + LIMIT 20`, + [String(goalRunId)], + ); + return { + ...goal, + username: row.username == null ? null : String(row.username), + context: parseJson(row.context_json, goal.context), + agentRuns: agentRuns.map((run) => ({ + id: String(run.id), + status: String(run.status), + requestId: String(run.request_id), + goalCheckpointId: run.goal_checkpoint_id == null ? null : String(run.goal_checkpoint_id), + createdAt: Number(run.created_at ?? 0), + updatedAt: Number(run.updated_at ?? 0), + completedAt: run.completed_at == null ? null : Number(run.completed_at), + })), + canaryEnabled: isGoalRunEnabledForUser(String(row.user_id), env), + }; + }, + + async approveCheckpoint({ goalRunId, checkpointId, feedback = null, reviewedBy = null } = {}) { + await ensureSchema(); + const [rows] = await pool.query( + 'SELECT user_id FROM h5_goal_runs WHERE id = ? LIMIT 1', + [String(goalRunId)], + ); + const userId = rows[0]?.user_id == null ? null : String(rows[0].user_id); + if (!userId) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + throw error; + } + const note = feedback ?? (reviewedBy ? `admin:${reviewedBy}` : null); + return goalRunService.approveCheckpoint({ + userId, + goalRunId, + checkpointId, + feedback: note, + }); + }, + + async cancelGoal({ goalRunId, reviewedBy = null } = {}) { + await ensureSchema(); + const [rows] = await pool.query( + 'SELECT user_id FROM h5_goal_runs WHERE id = ? LIMIT 1', + [String(goalRunId)], + ); + const userId = rows[0]?.user_id == null ? null : String(rows[0].user_id); + if (!userId) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + throw error; + } + return goalRunService.cancelGoal({ + userId, + goalRunId, + }); + }, + }; +} diff --git a/goal-run-admin-ops.test.mjs b/goal-run-admin-ops.test.mjs new file mode 100644 index 0000000..5c041cd --- /dev/null +++ b/goal-run-admin-ops.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGoalRunAdminOpsService } from './goal-run-admin-ops.mjs'; + +test('createGoalRunAdminOpsService reports runtime flags', () => { + const service = createGoalRunAdminOpsService( + { async query() { return [[]]; } }, + { + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-a, user-b', + }, + }, + ); + const runtime = service.getRuntime(); + assert.equal(runtime.enabled, true); + assert.deepEqual(runtime.canaryUserIds, ['user-a', 'user-b']); + assert.equal(runtime.canaryMode, true); +}); + +test('listGoals returns normalized rows with checkpoint counts', async () => { + const service = createGoalRunAdminOpsService({ + async query(sql, params) { + if (String(sql).includes('CREATE TABLE')) return [[]]; + if (String(sql).includes('information_schema')) return [[{ ok: 1 }]]; + if (String(sql).includes('GROUP BY status')) { + return [[{ status: 'active', count: 2 }]]; + } + if (String(sql).includes('FROM h5_goal_runs g')) { + return [[{ + id: 'goal-1', + user_id: 'user-1', + username: 'john2', + title: '产品规划', + intent_summary: '分阶段完成', + status: 'active', + priority: 5, + source_channel: 'h5', + source_session_id: null, + current_checkpoint_id: 'cp-1', + checkpoint_count: 2, + active_agent_run_count: 1, + created_at: 1000, + updated_at: 2000, + completed_at: null, + }]]; + } + if (String(sql).includes('FROM h5_goal_checkpoints WHERE goal_run_id')) { + return [[ + { + id: 'cp-1', + goal_run_id: 'goal-1', + sequence: 1, + title: '调研', + description: null, + status: 'running', + agent_run_id: null, + output_summary: null, + output_artifact_ids: null, + user_feedback: null, + approved_at: null, + created_at: 1000, + updated_at: 2000, + started_at: 1500, + completed_at: null, + }, + ]]; + } + if (String(sql).includes('FROM h5_goal_runs WHERE id = ? AND user_id = ?')) { + return [[{ + id: 'goal-1', + user_id: 'user-1', + title: '产品规划', + intent_summary: '分阶段完成', + status: 'active', + priority: 5, + source_channel: 'h5', + source_session_id: null, + source_message_id: null, + current_checkpoint_id: 'cp-1', + context_json: null, + memory_snapshot_json: null, + created_at: 1000, + updated_at: 2000, + completed_at: null, + }]]; + } + if (String(sql).includes('FROM h5_agent_runs') && String(sql).includes('goal_run_id')) { + return [[{ + id: 'run-1', + status: 'running', + request_id: 'req-1', + goal_checkpoint_id: 'cp-1', + created_at: 1000, + updated_at: 2000, + completed_at: null, + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }, { + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-1', + }, + }); + + const counts = await service.countByStatus(); + assert.equal(counts.active, 2); + + const items = await service.listGoals({ limit: 10 }); + assert.equal(items.length, 1); + assert.equal(items[0].username, 'john2'); + assert.equal(items[0].checkpointCount, 2); + + const detail = await service.getGoalDetail('goal-1'); + assert.equal(detail?.title, '产品规划'); + assert.equal(detail?.checkpoints.length, 1); + assert.equal(detail?.agentRuns[0].id, 'run-1'); + assert.equal(detail?.canaryEnabled, true); +}); diff --git a/goal-run-awaiting.mjs b/goal-run-awaiting.mjs new file mode 100644 index 0000000..e714479 --- /dev/null +++ b/goal-run-awaiting.mjs @@ -0,0 +1,14 @@ +export function findAwaitingApprovalCheckpoint(goal) { + const checkpoints = Array.isArray(goal?.checkpoints) ? goal.checkpoints : []; + return checkpoints.find((item) => item?.status === 'awaiting_approval') ?? null; +} + +export function listAwaitingGoalRuns(goals) { + if (!Array.isArray(goals)) return []; + return goals + .map((goal) => { + const checkpoint = findAwaitingApprovalCheckpoint(goal); + return checkpoint ? { goal, checkpoint } : null; + }) + .filter(Boolean); +} diff --git a/goal-run-awaiting.test.mjs b/goal-run-awaiting.test.mjs new file mode 100644 index 0000000..453230d --- /dev/null +++ b/goal-run-awaiting.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { findAwaitingApprovalCheckpoint, listAwaitingGoalRuns } from './goal-run-awaiting.mjs'; + +test('findAwaitingApprovalCheckpoint returns awaiting checkpoint', () => { + const checkpoint = findAwaitingApprovalCheckpoint({ + id: 'goal-1', + checkpoints: [ + { id: 'cp-1', status: 'approved' }, + { id: 'cp-2', status: 'awaiting_approval' }, + ], + }); + assert.equal(checkpoint?.id, 'cp-2'); +}); + +test('listAwaitingGoalRuns filters non-awaiting goals', () => { + const items = listAwaitingGoalRuns([ + { + id: 'goal-1', + checkpoints: [{ id: 'cp-1', status: 'running' }], + }, + { + id: 'goal-2', + checkpoints: [{ id: 'cp-2', status: 'awaiting_approval', title: '输出' }], + }, + ]); + assert.equal(items.length, 1); + assert.equal(items[0].goal.id, 'goal-2'); + assert.equal(items[0].checkpoint.id, 'cp-2'); +}); diff --git a/goal-run-context.mjs b/goal-run-context.mjs new file mode 100644 index 0000000..8207198 --- /dev/null +++ b/goal-run-context.mjs @@ -0,0 +1,42 @@ +const CHECKPOINT_STATUS_LABELS = { + pending: '待开始', + running: '进行中', + awaiting_approval: '待确认', + approved: '已完成', + skipped: '已跳过', + failed: '失败', +}; + +export function buildGoalContextEnvelope(goal) { + if (!goal) return ''; + const checkpoints = Array.isArray(goal.checkpoints) ? goal.checkpoints : []; + const current = checkpoints.find((item) => item.id === goal.currentCheckpointId) + ?? checkpoints.find((item) => item.status === 'running') + ?? checkpoints.find((item) => item.status === 'pending'); + const completed = checkpoints.filter((item) => + ['approved', 'awaiting_approval'].includes(item.status)); + const awaiting = checkpoints.find((item) => item.status === 'awaiting_approval'); + + const lines = [ + '【Goal Context】', + `目标:${goal.title}`, + ]; + if (current) { + const statusLabel = CHECKPOINT_STATUS_LABELS[current.status] ?? current.status; + lines.push(`当前阶段:${current.title}(${statusLabel})`); + } + if (completed.length) { + lines.push('已完成摘要:'); + for (const checkpoint of completed) { + if (checkpoint.outputSummary) { + lines.push(`- ${checkpoint.outputSummary}`); + } else { + lines.push(`- ${checkpoint.title}`); + } + } + } + if (awaiting) { + lines.push(`待你确认:${awaiting.description || awaiting.title}`); + } + return lines.join('\n'); +} diff --git a/goal-run-gateway.test.mjs b/goal-run-gateway.test.mjs new file mode 100644 index 0000000..2584047 --- /dev/null +++ b/goal-run-gateway.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAgentRunGateway } from './agent-run-gateway.mjs'; +import { buildAgentOrchestrationAgentText } from './chat-intent-router.mjs'; + +function createGoalRunPool() { + const runs = new Map(); + const events = []; + return { + runs, + events, + async query(sql, params = []) { + if (sql.includes('SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ?')) { + const [userId, requestId] = params; + const row = [...runs.values()].find( + (item) => item.user_id === userId && item.request_id === requestId, + ); + return [[row].filter(Boolean)]; + } + if (sql.includes('agent_session_id = ?') && sql.includes("status NOT IN ('succeeded', 'failed')")) { + return [[]]; + } + if (sql.includes('INSERT INTO h5_agent_runs')) { + const [ + id, + userId, + sessionId, + goalRunId, + goalCheckpointId, + requestId, + userMessageJson, + createdAt, + updatedAt, + ] = params; + runs.set(id, { + id, + user_id: userId, + agent_session_id: sessionId, + goal_run_id: goalRunId, + goal_checkpoint_id: goalCheckpointId, + request_id: requestId, + status: 'queued', + attempts: 0, + user_message_json: userMessageJson, + created_at: createdAt, + updated_at: updatedAt, + }); + return [{ affectedRows: 1 }]; + } + if (sql.includes('INSERT INTO h5_agent_run_events')) { + events.push({ + runId: params[1], + eventType: params[2], + dataJson: params[3], + }); + return [{ affectedRows: 1 }]; + } + if (sql.includes('SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1')) { + return [[runs.get(params[0])].filter(Boolean)]; + } + if (sql.includes('INSERT INTO h5_agent_run_snapshots')) { + return [{ affectedRows: 1 }]; + } + throw new Error(`Unexpected SQL: ${sql.slice(0, 120)}`); + }, + }; +} + +test('createRun persists goal_run_id and goal_checkpoint_id', async () => { + const pool = createGoalRunPool(); + const attachCalls = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + goalRunService: { + async attachAgentRunToCheckpoint(input) { + attachCalls.push(input); + return true; + }, + }, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-goal-1', + userMessage: { role: 'user', content: [{ type: 'text', text: '分阶段任务' }] }, + goalRunId: 'goal-1', + goalCheckpointId: 'cp-1', + }); + + assert.equal(run.requestId, 'req-goal-1'); + const stored = [...pool.runs.values()][0]; + assert.equal(stored.goal_run_id, 'goal-1'); + assert.equal(stored.goal_checkpoint_id, 'cp-1'); + assert.deepEqual(attachCalls, [{ + checkpointId: 'cp-1', + agentRunId: stored.id, + }]); + const queuedEvent = pool.events.find((event) => event.eventType === 'queued'); + assert.match(String(queuedEvent?.dataJson ?? ''), /"goalRunId":"goal-1"/); +}); + +test('buildAgentOrchestrationAgentText injects goal context before memory context', () => { + const text = buildAgentOrchestrationAgentText({ + displayText: '继续推进', + classification: { reason: '长期任务续作' }, + goalContext: { + injectionEnabled: true, + envelope: '【Goal Context】\n目标:产品规划\n当前阶段:调研(进行中)', + }, + memoryContext: { + injectionEnabled: true, + memories: [{ label: '偏好', text: '简洁中文' }], + }, + }); + const goalIndex = text.indexOf('【Goal Context】'); + const memoryIndex = text.indexOf('[Memory Context]'); + assert.ok(goalIndex >= 0); + assert.ok(memoryIndex > goalIndex); + assert.match(text, /目标:产品规划/); +}); diff --git a/goal-run-policy.mjs b/goal-run-policy.mjs new file mode 100644 index 0000000..118b85d --- /dev/null +++ b/goal-run-policy.mjs @@ -0,0 +1,14 @@ +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function shouldRequireCheckpointApproval({ + env = process.env, + pendingCheckpointCount = 0, + totalCheckpointCount = 1, +} = {}) { + if (envFlag(env.GOAL_RUN_REQUIRE_APPROVAL, false)) return true; + return pendingCheckpointCount > 0 && totalCheckpointCount > 1; +} diff --git a/goal-run-policy.test.mjs b/goal-run-policy.test.mjs new file mode 100644 index 0000000..d6376ab --- /dev/null +++ b/goal-run-policy.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldRequireCheckpointApproval } from './goal-run-policy.mjs'; + +test('shouldRequireCheckpointApproval gates multi-stage checkpoints by default', () => { + assert.equal( + shouldRequireCheckpointApproval({ + env: {}, + pendingCheckpointCount: 1, + totalCheckpointCount: 2, + }), + true, + ); + assert.equal( + shouldRequireCheckpointApproval({ + env: {}, + pendingCheckpointCount: 0, + totalCheckpointCount: 1, + }), + false, + ); +}); + +test('shouldRequireCheckpointApproval honors GOAL_RUN_REQUIRE_APPROVAL', () => { + assert.equal( + shouldRequireCheckpointApproval({ + env: { GOAL_RUN_REQUIRE_APPROVAL: '1' }, + pendingCheckpointCount: 0, + totalCheckpointCount: 1, + }), + true, + ); +}); diff --git a/goal-run-resolve.mjs b/goal-run-resolve.mjs new file mode 100644 index 0000000..c2f8e88 --- /dev/null +++ b/goal-run-resolve.mjs @@ -0,0 +1,67 @@ +import { + deriveGoalTitleFromText, + extractUserMessageDisplayText, + isGoalRunEnabledForUser, + isGoalRunIntent, +} from './goal-run-intent.mjs'; + +export async function resolveGoalBindingForAgentRun({ + goalRunService = null, + chatIntentRouter = null, + userId, + userMessage, + sessionId = null, + body = {}, + env = process.env, +} = {}) { + if (!goalRunService || !isGoalRunEnabledForUser(userId, env)) { + return null; + } + + const explicitGoalRunId = String(body.goal_run_id ?? body.goalRunId ?? '').trim() || null; + if (explicitGoalRunId) { + const goal = await goalRunService.getGoalRun({ userId, goalRunId: explicitGoalRunId }); + if (!goal) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + error.status = 404; + throw error; + } + const started = await goalRunService.startNextCheckpoint({ + userId, + goalRunId: explicitGoalRunId, + }); + return { + goalRunId: explicitGoalRunId, + goalCheckpointId: started.checkpointId, + }; + } + + const displayText = extractUserMessageDisplayText(userMessage); + let shouldCreateGoal = isGoalRunIntent(displayText); + if (!shouldCreateGoal && chatIntentRouter?.classify) { + const classification = await chatIntentRouter.classify({ + userId, + userMessage, + sessionId, + toolMode: 'chat', + }); + shouldCreateGoal = classification?.decision?.flags?.includes('goal_run') === true; + } + if (!shouldCreateGoal) return null; + + const goal = await goalRunService.createGoalRun({ + userId, + title: deriveGoalTitleFromText(displayText), + intentSummary: displayText || '长期任务', + sourceSessionId: sessionId, + }); + const started = await goalRunService.startNextCheckpoint({ + userId, + goalRunId: goal.id, + }); + return { + goalRunId: goal.id, + goalCheckpointId: started.checkpointId, + }; +} diff --git a/goal-run-resolve.test.mjs b/goal-run-resolve.test.mjs new file mode 100644 index 0000000..5213912 --- /dev/null +++ b/goal-run-resolve.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveGoalBindingForAgentRun } from './goal-run-resolve.mjs'; + +function createGoalRunServiceStub(overrides = {}) { + return { + async createGoalRun(input) { + return { + id: 'goal-new', + title: input.title, + checkpoints: [{ id: 'cp-new', title: '启动', status: 'pending' }], + currentCheckpointId: 'cp-new', + }; + }, + async getGoalRun({ goalRunId }) { + if (goalRunId === 'goal-existing') { + return { + id: 'goal-existing', + status: 'active', + checkpoints: [{ id: 'cp-2', status: 'pending' }], + }; + } + return null; + }, + async startNextCheckpoint({ goalRunId }) { + return { + goalRunId, + checkpointId: goalRunId === 'goal-existing' ? 'cp-2' : 'cp-new', + }; + }, + ...overrides, + }; +} + +test('resolveGoalBindingForAgentRun returns null when feature disabled', async () => { + const binding = await resolveGoalBindingForAgentRun({ + goalRunService: createGoalRunServiceStub(), + userId: 'user-1', + userMessage: { content: [{ type: 'text', text: '帮我分阶段完成产品规划' }] }, + env: { GOAL_RUN_ENABLED: '0' }, + }); + assert.equal(binding, null); +}); + +test('resolveGoalBindingForAgentRun creates goal for goal_run intent', async () => { + const calls = []; + const binding = await resolveGoalBindingForAgentRun({ + goalRunService: createGoalRunServiceStub({ + async createGoalRun(input) { + calls.push(input); + return { + id: 'goal-new', + title: input.title, + checkpoints: [{ id: 'cp-new' }], + }; + }, + }), + userId: 'user-canary', + userMessage: { + content: [{ type: 'text', text: '帮我分阶段完成下季度产品规划' }], + metadata: { displayText: '帮我分阶段完成下季度产品规划' }, + }, + sessionId: 'session-1', + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-canary', + }, + }); + assert.deepEqual(binding, { + goalRunId: 'goal-new', + goalCheckpointId: 'cp-new', + }); + assert.equal(calls[0].sourceSessionId, 'session-1'); +}); + +test('resolveGoalBindingForAgentRun resumes explicit goal_run_id', async () => { + const binding = await resolveGoalBindingForAgentRun({ + goalRunService: createGoalRunServiceStub(), + userId: 'user-canary', + userMessage: { content: [{ type: 'text', text: '继续' }] }, + body: { goal_run_id: 'goal-existing' }, + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-canary', + }, + }); + assert.deepEqual(binding, { + goalRunId: 'goal-existing', + goalCheckpointId: 'cp-2', + }); +}); + +test('resolveGoalBindingForAgentRun uses router goal_run flag', async () => { + const binding = await resolveGoalBindingForAgentRun({ + goalRunService: createGoalRunServiceStub(), + chatIntentRouter: { + async classify() { + return { decision: { flags: ['goal_run'] } }; + }, + }, + userId: 'user-canary', + userMessage: { content: [{ type: 'text', text: '整理一下思路' }] }, + env: { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-canary', + }, + }); + assert.equal(binding?.goalRunId, 'goal-new'); +}); diff --git a/goal-run-service.mjs b/goal-run-service.mjs new file mode 100644 index 0000000..3b586a4 --- /dev/null +++ b/goal-run-service.mjs @@ -0,0 +1,661 @@ +import crypto from 'node:crypto'; +import { buildGoalContextEnvelope as buildGoalContextEnvelopeText } from './goal-run-context.mjs'; +import { shouldRequireCheckpointApproval } from './goal-run-policy.mjs'; + +const GOAL_TABLE = 'h5_goal_runs'; +const CHECKPOINT_TABLE = 'h5_goal_checkpoints'; + +const TERMINAL_CHECKPOINT_STATUSES = new Set([ + 'approved', + 'skipped', + 'failed', +]); + +export function buildGoalRunSchemaSql() { + return [ + `CREATE TABLE IF NOT EXISTS \`${GOAL_TABLE}\` ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + title VARCHAR(500) NOT NULL, + intent_summary TEXT NOT NULL, + status ENUM( + 'draft', + 'active', + 'paused', + 'awaiting_user', + 'completed', + 'failed', + 'cancelled' + ) NOT NULL DEFAULT 'active', + priority TINYINT UNSIGNED NOT NULL DEFAULT 5, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'h5', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + current_checkpoint_id CHAR(36) NULL, + context_json JSON NULL, + memory_snapshot_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + completed_at BIGINT NULL, + KEY idx_goal_user_status (user_id, status, updated_at), + KEY idx_goal_user_active (user_id, status, created_at), + CONSTRAINT fk_goal_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS \`${CHECKPOINT_TABLE}\` ( + id CHAR(36) PRIMARY KEY, + goal_run_id CHAR(36) NOT NULL, + sequence INT UNSIGNED NOT NULL, + title VARCHAR(300) NOT NULL, + description TEXT NULL, + status ENUM( + 'pending', + 'running', + 'awaiting_approval', + 'approved', + 'skipped', + 'failed' + ) NOT NULL DEFAULT 'pending', + agent_run_id CHAR(36) NULL, + output_summary TEXT NULL, + output_artifact_ids JSON NULL, + user_feedback TEXT NULL, + approved_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + started_at BIGINT NULL, + completed_at BIGINT NULL, + UNIQUE KEY uq_goal_checkpoint_seq (goal_run_id, sequence), + KEY idx_checkpoint_goal_status (goal_run_id, status, sequence), + CONSTRAINT fk_checkpoint_goal FOREIGN KEY (goal_run_id) REFERENCES \`${GOAL_TABLE}\`(id) ON DELETE CASCADE, + CONSTRAINT fk_checkpoint_agent_run FOREIGN KEY (agent_run_id) REFERENCES h5_agent_runs(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ]; +} + +export async function ensureGoalRunSchema(pool, { columnExists, indexExists } = {}) { + if (!pool?.query) throw new Error('Goal run schema requires a MySQL pool'); + for (const sql of buildGoalRunSchemaSql()) { + await pool.query(sql); + } + const hasColumn = columnExists ?? (async (table, column) => { + const [rows] = await pool.query( + `SELECT 1 AS ok FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? + LIMIT 1`, + [table, column], + ); + return rows.length > 0; + }); + const hasIndex = indexExists ?? (async (table, index) => { + const [rows] = await pool.query( + `SELECT 1 AS ok FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? + LIMIT 1`, + [table, index], + ); + return rows.length > 0; + }); + + if (!(await hasColumn('h5_agent_runs', 'goal_run_id'))) { + await pool.query( + 'ALTER TABLE h5_agent_runs ADD COLUMN goal_run_id CHAR(36) NULL AFTER agent_session_id', + ); + } + if (!(await hasColumn('h5_agent_runs', 'goal_checkpoint_id'))) { + await pool.query( + 'ALTER TABLE h5_agent_runs ADD COLUMN goal_checkpoint_id CHAR(36) NULL AFTER goal_run_id', + ); + } + if (!(await hasIndex('h5_agent_runs', 'idx_agent_run_goal'))) { + await pool.query( + 'ALTER TABLE h5_agent_runs ADD KEY idx_agent_run_goal (goal_run_id, updated_at)', + ); + } +} + +function parseJson(value, fallback = null) { + if (value == null) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(String(value)); + } catch { + return fallback; + } +} + +function normalizeGoalRow(row) { + return { + id: String(row.id), + userId: String(row.user_id), + title: String(row.title), + intentSummary: String(row.intent_summary), + status: String(row.status), + priority: Number(row.priority ?? 5), + sourceChannel: String(row.source_channel ?? 'h5'), + sourceSessionId: row.source_session_id == null ? null : String(row.source_session_id), + sourceMessageId: row.source_message_id == null ? null : String(row.source_message_id), + currentCheckpointId: row.current_checkpoint_id == null ? null : String(row.current_checkpoint_id), + context: parseJson(row.context_json, null), + memorySnapshot: parseJson(row.memory_snapshot_json, null), + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + completedAt: row.completed_at == null ? null : Number(row.completed_at), + }; +} + +function normalizeCheckpointRow(row) { + return { + id: String(row.id), + goalRunId: String(row.goal_run_id), + sequence: Number(row.sequence ?? 0), + title: String(row.title), + description: row.description == null ? null : String(row.description), + status: String(row.status), + agentRunId: row.agent_run_id == null ? null : String(row.agent_run_id), + outputSummary: row.output_summary == null ? null : String(row.output_summary), + outputArtifactIds: parseJson(row.output_artifact_ids, []), + userFeedback: row.user_feedback == null ? null : String(row.user_feedback), + approvedAt: row.approved_at == null ? null : Number(row.approved_at), + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + startedAt: row.started_at == null ? null : Number(row.started_at), + completedAt: row.completed_at == null ? null : Number(row.completed_at), + }; +} + +export function createGoalRunService({ pool = null, now = () => Date.now(), env = process.env } = {}) { + if (!pool?.query) return null; + + async function advanceGoalAfterCheckpointApproved(goal, approvedCheckpoint) { + const timestamp = now(); + const nextPending = goal.checkpoints.find( + (item) => item.sequence > approvedCheckpoint.sequence + && ['pending', 'failed'].includes(item.status), + ); + if (nextPending) { + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'active', current_checkpoint_id = ?, updated_at = ? + WHERE id = ?`, + [nextPending.id, timestamp, goal.id], + ); + return { nextCheckpointId: nextPending.id, goalCompleted: false }; + } + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'completed', + current_checkpoint_id = ?, + completed_at = ?, + updated_at = ? + WHERE id = ?`, + [approvedCheckpoint.id, timestamp, timestamp, goal.id], + ); + return { nextCheckpointId: null, goalCompleted: true }; + } + + return { + ensureSchema: () => ensureGoalRunSchema(pool), + + async createGoalRun({ + userId, + title, + intentSummary, + sourceChannel = 'h5', + sourceSessionId = null, + sourceMessageId = null, + checkpoints = [], + context = null, + memorySnapshot = null, + } = {}) { + if (!userId || !title || !intentSummary) { + throw new Error('createGoalRun requires userId, title, and intentSummary'); + } + const goalId = crypto.randomUUID(); + const timestamp = now(); + const initialCheckpoints = checkpoints.length + ? checkpoints + : [{ title: '启动', description: intentSummary }]; + await pool.query( + `INSERT INTO ${GOAL_TABLE} + (id, user_id, title, intent_summary, status, priority, source_channel, + source_session_id, source_message_id, context_json, memory_snapshot_json, + created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', 5, ?, ?, ?, ?, ?, ?, ?)`, + [ + goalId, + String(userId), + String(title).slice(0, 500), + String(intentSummary), + String(sourceChannel), + sourceSessionId, + sourceMessageId, + context == null ? null : JSON.stringify(context), + memorySnapshot == null ? null : JSON.stringify(memorySnapshot), + timestamp, + timestamp, + ], + ); + + let firstCheckpointId = null; + for (let index = 0; index < initialCheckpoints.length; index += 1) { + const checkpoint = initialCheckpoints[index]; + const checkpointId = crypto.randomUUID(); + if (index === 0) firstCheckpointId = checkpointId; + await pool.query( + `INSERT INTO ${CHECKPOINT_TABLE} + (id, goal_run_id, sequence, title, description, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + checkpointId, + goalId, + index + 1, + String(checkpoint.title ?? `阶段 ${index + 1}`).slice(0, 300), + checkpoint.description == null ? null : String(checkpoint.description), + index === 0 ? 'pending' : 'pending', + timestamp, + timestamp, + ], + ); + } + + if (firstCheckpointId) { + await pool.query( + `UPDATE ${GOAL_TABLE} SET current_checkpoint_id = ?, updated_at = ? WHERE id = ?`, + [firstCheckpointId, timestamp, goalId], + ); + } + + return this.getGoalRun({ userId, goalRunId: goalId }); + }, + + async getGoalRun({ userId, goalRunId } = {}) { + if (!userId || !goalRunId) return null; + const [rows] = await pool.query( + `SELECT * FROM ${GOAL_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`, + [String(goalRunId), String(userId)], + ); + const row = rows[0]; + if (!row) return null; + const goal = normalizeGoalRow(row); + const [checkpointRows] = await pool.query( + `SELECT * FROM ${CHECKPOINT_TABLE} WHERE goal_run_id = ? ORDER BY sequence ASC`, + [goal.id], + ); + goal.checkpoints = checkpointRows.map(normalizeCheckpointRow); + return goal; + }, + + async listGoalRuns({ userId, statuses = ['active', 'awaiting_user', 'paused'], limit = 20 } = {}) { + if (!userId) return []; + const normalizedStatuses = (statuses ?? []).map(String).filter(Boolean); + if (!normalizedStatuses.length) return []; + const placeholders = normalizedStatuses.map(() => '?').join(', '); + const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)); + const [rows] = await pool.query( + `SELECT * FROM ${GOAL_TABLE} + WHERE user_id = ? AND status IN (${placeholders}) + ORDER BY updated_at DESC + LIMIT ?`, + [String(userId), ...normalizedStatuses, safeLimit], + ); + return rows.map(normalizeGoalRow); + }, + + buildGoalContextEnvelope(goal) { + return buildGoalContextEnvelopeText(goal); + }, + + async startNextCheckpoint({ userId, goalRunId, agentRunId = null } = {}) { + if (!userId || !goalRunId) { + throw new Error('startNextCheckpoint requires userId and goalRunId'); + } + const goal = await this.getGoalRun({ userId, goalRunId }); + if (!goal) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + throw error; + } + if (!['active', 'awaiting_user'].includes(goal.status)) { + const error = new Error(`目标状态 ${goal.status} 不可启动阶段`); + error.code = 'GOAL_RUN_NOT_STARTABLE'; + throw error; + } + + const running = goal.checkpoints.find((item) => item.status === 'running'); + if (running) { + if (agentRunId && !running.agentRunId) { + const timestamp = now(); + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET agent_run_id = ?, updated_at = ? + WHERE id = ? AND goal_run_id = ?`, + [String(agentRunId), timestamp, running.id, goal.id], + ); + } + return { + goalRunId: goal.id, + checkpointId: running.id, + checkpoint: running, + }; + } + + const nextCheckpoint = goal.checkpoints.find((item) => item.status === 'pending') + ?? goal.checkpoints.find((item) => item.status === 'failed'); + if (!nextCheckpoint) { + const error = new Error('没有可启动的阶段'); + error.code = 'GOAL_CHECKPOINT_UNAVAILABLE'; + throw error; + } + + const timestamp = now(); + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'running', + agent_run_id = ?, + started_at = COALESCE(started_at, ?), + updated_at = ? + WHERE id = ? AND goal_run_id = ?`, + [ + agentRunId ? String(agentRunId) : null, + timestamp, + timestamp, + nextCheckpoint.id, + goal.id, + ], + ); + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'active', current_checkpoint_id = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [nextCheckpoint.id, timestamp, goal.id, String(userId)], + ); + + return { + goalRunId: goal.id, + checkpointId: nextCheckpoint.id, + checkpoint: { + ...nextCheckpoint, + status: 'running', + agentRunId: agentRunId ? String(agentRunId) : nextCheckpoint.agentRunId, + }, + }; + }, + + async attachAgentRunToCheckpoint({ checkpointId, agentRunId } = {}) { + if (!checkpointId || !agentRunId) return false; + const timestamp = now(); + const [result] = await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET agent_run_id = ?, updated_at = ? + WHERE id = ? AND (agent_run_id IS NULL OR agent_run_id = ?)`, + [String(agentRunId), timestamp, String(checkpointId), String(agentRunId)], + ); + return Number(result?.affectedRows ?? 0) > 0; + }, + + async onAgentRunCompleted({ agentRunId, status, outputSummary = null } = {}) { + if (!agentRunId) return { handled: false, reason: 'missing_agent_run_id' }; + const [runRows] = await pool.query( + `SELECT id, goal_run_id, goal_checkpoint_id, status + FROM h5_agent_runs + WHERE id = ? + LIMIT 1`, + [String(agentRunId)], + ); + const run = runRows[0]; + if (!run?.goal_checkpoint_id) return { handled: false, reason: 'not_goal_run' }; + + const [checkpointRows] = await pool.query( + `SELECT c.*, g.user_id, g.status AS goal_status + FROM ${CHECKPOINT_TABLE} c + INNER JOIN ${GOAL_TABLE} g ON g.id = c.goal_run_id + WHERE c.id = ? + LIMIT 1`, + [String(run.goal_checkpoint_id)], + ); + const checkpoint = checkpointRows[0]; + if (!checkpoint) return { handled: false, reason: 'checkpoint_missing' }; + if (TERMINAL_CHECKPOINT_STATUSES.has(String(checkpoint.status))) { + return { handled: true, reason: 'already_terminal', checkpointId: checkpoint.id }; + } + + const timestamp = now(); + const normalizedStatus = String(status ?? run.status ?? '').trim(); + if (normalizedStatus === 'succeeded') { + const [pendingRows] = await pool.query( + `SELECT id FROM ${CHECKPOINT_TABLE} + WHERE goal_run_id = ? AND status IN ('pending', 'failed') + ORDER BY sequence ASC`, + [checkpoint.goal_run_id], + ); + const [countRows] = await pool.query( + `SELECT COUNT(*) AS total FROM ${CHECKPOINT_TABLE} WHERE goal_run_id = ?`, + [checkpoint.goal_run_id], + ); + const pendingCheckpointCount = pendingRows.length; + const totalCheckpointCount = Number(countRows[0]?.total ?? 0); + const requiresApproval = shouldRequireCheckpointApproval({ + env, + pendingCheckpointCount, + totalCheckpointCount, + }); + + if (requiresApproval) { + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'awaiting_approval', + output_summary = COALESCE(?, output_summary), + completed_at = ?, + updated_at = ? + WHERE id = ? AND status = 'running'`, + [ + outputSummary == null ? null : String(outputSummary).slice(0, 4000), + timestamp, + timestamp, + checkpoint.id, + ], + ); + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'awaiting_user', updated_at = ? + WHERE id = ?`, + [timestamp, checkpoint.goal_run_id], + ); + return { + handled: true, + checkpointId: checkpoint.id, + goalRunId: checkpoint.goal_run_id, + awaitingApproval: true, + }; + } + + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'approved', + output_summary = COALESCE(?, output_summary), + completed_at = ?, + approved_at = COALESCE(approved_at, ?), + updated_at = ? + WHERE id = ? AND status = 'running'`, + [ + outputSummary == null ? null : String(outputSummary).slice(0, 4000), + timestamp, + timestamp, + timestamp, + checkpoint.id, + ], + ); + if (pendingRows[0]?.id) { + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'active', + current_checkpoint_id = ?, + updated_at = ? + WHERE id = ?`, + [pendingRows[0].id, timestamp, checkpoint.goal_run_id], + ); + return { + handled: true, + checkpointId: checkpoint.id, + goalRunId: checkpoint.goal_run_id, + nextCheckpointId: pendingRows[0].id, + }; + } + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'completed', + current_checkpoint_id = ?, + completed_at = ?, + updated_at = ? + WHERE id = ?`, + [checkpoint.id, timestamp, timestamp, checkpoint.goal_run_id], + ); + return { + handled: true, + checkpointId: checkpoint.id, + goalRunId: checkpoint.goal_run_id, + goalCompleted: true, + }; + } + + if (normalizedStatus === 'failed') { + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'failed', + output_summary = COALESCE(?, output_summary), + completed_at = ?, + updated_at = ? + WHERE id = ? AND status = 'running'`, + [ + outputSummary == null ? null : String(outputSummary).slice(0, 4000), + timestamp, + checkpoint.id, + ], + ); + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'failed', updated_at = ? + WHERE id = ? AND status <> 'cancelled'`, + [timestamp, checkpoint.goal_run_id], + ); + return { + handled: true, + checkpointId: checkpoint.id, + goalRunId: checkpoint.goal_run_id, + failed: true, + }; + } + + return { handled: false, reason: 'non_terminal_status', status: normalizedStatus }; + }, + + async approveCheckpoint({ + userId, + goalRunId, + checkpointId, + feedback = null, + } = {}) { + const goal = await this.getGoalRun({ userId, goalRunId }); + if (!goal) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + throw error; + } + const checkpoint = goal.checkpoints.find((item) => item.id === String(checkpointId)); + if (!checkpoint) { + const error = new Error('阶段不存在'); + error.code = 'GOAL_CHECKPOINT_NOT_FOUND'; + throw error; + } + if (checkpoint.status !== 'awaiting_approval') { + const error = new Error(`阶段状态 ${checkpoint.status} 不可确认`); + error.code = 'GOAL_CHECKPOINT_NOT_APPROVABLE'; + throw error; + } + const timestamp = now(); + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'approved', + user_feedback = ?, + approved_at = ?, + updated_at = ? + WHERE id = ? AND goal_run_id = ?`, + [ + feedback == null ? null : String(feedback).slice(0, 4000), + timestamp, + timestamp, + checkpoint.id, + goal.id, + ], + ); + const refreshed = await this.getGoalRun({ userId, goalRunId: goal.id }); + const approvedCheckpoint = refreshed.checkpoints.find((item) => item.id === checkpoint.id); + await advanceGoalAfterCheckpointApproved(refreshed, approvedCheckpoint); + return this.getGoalRun({ userId, goalRunId: goal.id }); + }, + + async pauseGoal({ userId, goalRunId } = {}) { + const timestamp = now(); + const [result] = await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'paused', updated_at = ? + WHERE id = ? AND user_id = ? AND status IN ('active', 'awaiting_user')`, + [timestamp, String(goalRunId), String(userId)], + ); + if (Number(result?.affectedRows ?? 0) === 0) { + const error = new Error('目标不存在或不可暂停'); + error.code = 'GOAL_RUN_NOT_PAUSABLE'; + throw error; + } + return this.getGoalRun({ userId, goalRunId }); + }, + + async resumeGoal({ userId, goalRunId } = {}) { + const goal = await this.getGoalRun({ userId, goalRunId }); + if (!goal) { + const error = new Error('目标不存在'); + error.code = 'GOAL_RUN_NOT_FOUND'; + throw error; + } + if (!['paused', 'awaiting_user', 'failed'].includes(goal.status)) { + const error = new Error(`目标状态 ${goal.status} 不可续作`); + error.code = 'GOAL_RUN_NOT_RESUMABLE'; + throw error; + } + const timestamp = now(); + await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'active', updated_at = ? + WHERE id = ? AND user_id = ?`, + [timestamp, goal.id, String(userId)], + ); + const started = await this.startNextCheckpoint({ userId, goalRunId: goal.id }); + return { + goal: await this.getGoalRun({ userId, goalRunId: goal.id }), + checkpointId: started.checkpointId, + }; + }, + + async cancelGoal({ userId, goalRunId } = {}) { + const timestamp = now(); + const [result] = await pool.query( + `UPDATE ${GOAL_TABLE} + SET status = 'cancelled', updated_at = ?, completed_at = COALESCE(completed_at, ?) + WHERE id = ? AND user_id = ? AND status NOT IN ('completed', 'cancelled')`, + [timestamp, timestamp, String(goalRunId), String(userId)], + ); + if (Number(result?.affectedRows ?? 0) === 0) { + const error = new Error('目标不存在或已结束'); + error.code = 'GOAL_RUN_NOT_CANCELLABLE'; + throw error; + } + await pool.query( + `UPDATE ${CHECKPOINT_TABLE} + SET status = 'skipped', updated_at = ? + WHERE goal_run_id = ? AND status IN ('pending', 'running', 'awaiting_approval')`, + [timestamp, String(goalRunId)], + ); + return this.getGoalRun({ userId, goalRunId }); + }, + }; +} diff --git a/goal-run-service.test.mjs b/goal-run-service.test.mjs new file mode 100644 index 0000000..a42acf6 --- /dev/null +++ b/goal-run-service.test.mjs @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGoalRunService, ensureGoalRunSchema } from './goal-run-service.mjs'; + +test('ensureGoalRunSchema creates goal tables and agent run columns', async () => { + const queries = []; + const pool = { + async query(sql) { + queries.push(String(sql)); + return [[]]; + }, + }; + const columnExists = async (table, column) => { + if (table === 'h5_agent_runs' && ['goal_run_id', 'goal_checkpoint_id'].includes(column)) { + return false; + } + return true; + }; + const indexExists = async () => false; + await ensureGoalRunSchema(pool, { columnExists, indexExists }); + assert.ok(queries.some((sql) => sql.includes('h5_goal_runs'))); + assert.ok(queries.some((sql) => sql.includes('h5_goal_checkpoints'))); + assert.ok(queries.some((sql) => sql.includes('goal_run_id'))); +}); + +test('createGoalRun persists goal and default checkpoint', async () => { + const store = { goals: new Map(), checkpoints: new Map() }; + const pool = { + async query(sql, params) { + if (String(sql).startsWith('INSERT INTO h5_goal_runs')) { + store.goals.set(params[0], { + id: params[0], + user_id: params[1], + title: params[2], + intent_summary: params[3], + status: 'active', + source_channel: params[5], + current_checkpoint_id: null, + context_json: params[8], + memory_snapshot_json: params[9], + created_at: params[10], + updated_at: params[11], + completed_at: null, + priority: 5, + source_session_id: params[6], + source_message_id: params[7], + }); + return [{ affectedRows: 1 }]; + } + if (String(sql).startsWith('INSERT INTO h5_goal_checkpoints')) { + store.checkpoints.set(params[0], { + id: params[0], + goal_run_id: params[1], + sequence: params[2], + title: params[3], + description: params[4], + status: params[5], + agent_run_id: null, + output_summary: null, + output_artifact_ids: null, + user_feedback: null, + approved_at: null, + created_at: params[6], + updated_at: params[7], + started_at: null, + completed_at: null, + }); + return [{ affectedRows: 1 }]; + } + if (String(sql).startsWith('UPDATE h5_goal_runs SET current_checkpoint_id')) { + const goal = store.goals.get(params[2]); + if (goal) goal.current_checkpoint_id = params[0]; + return [{ affectedRows: 1 }]; + } + if (String(sql).includes('FROM h5_goal_runs WHERE id =')) { + const goal = store.goals.get(params[0]); + return [[goal].filter(Boolean)]; + } + if (String(sql).includes('FROM h5_goal_checkpoints WHERE goal_run_id')) { + return [[...store.checkpoints.values()].filter((item) => item.goal_run_id === params[0])]; + } + if (String(sql).includes('FROM h5_goal_runs') && String(sql).includes('status IN')) { + return [[...store.goals.values()].filter((item) => item.user_id === params[0])]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; + + const service = createGoalRunService({ pool, now: () => 1000 }); + const created = await service.createGoalRun({ + userId: 'user-1', + title: '准备下季度产品规划', + intentSummary: '收集竞品信息并输出规划草案', + sourceSessionId: 'session-1', + }); + assert.equal(created.title, '准备下季度产品规划'); + assert.equal(created.checkpoints.length, 1); + assert.equal(created.checkpoints[0].title, '启动'); + assert.equal(created.currentCheckpointId, created.checkpoints[0].id); + + const listed = await service.listGoalRuns({ userId: 'user-1' }); + assert.equal(listed.length, 1); +}); + +test('startNextCheckpoint and onAgentRunCompleted advance goal lifecycle', async () => { + const store = { + goals: new Map([ + ['goal-1', { + id: 'goal-1', + user_id: 'user-1', + title: '长期任务', + intent_summary: '分阶段完成', + status: 'active', + source_channel: 'h5', + current_checkpoint_id: 'cp-1', + context_json: null, + memory_snapshot_json: null, + created_at: 1000, + updated_at: 1000, + completed_at: null, + priority: 5, + source_session_id: null, + source_message_id: null, + }], + ]), + checkpoints: new Map([ + ['cp-1', { + id: 'cp-1', + goal_run_id: 'goal-1', + sequence: 1, + title: '启动', + description: null, + status: 'pending', + agent_run_id: null, + output_summary: null, + output_artifact_ids: null, + user_feedback: null, + approved_at: null, + created_at: 1000, + updated_at: 1000, + started_at: null, + completed_at: null, + }], + ]), + runs: new Map([ + ['run-1', { + id: 'run-1', + goal_run_id: 'goal-1', + goal_checkpoint_id: 'cp-1', + status: 'succeeded', + }], + ]), + }; + const pool = { + async query(sql, params) { + const text = String(sql); + if (text.includes('FROM h5_agent_runs') && text.includes('WHERE id = ?')) { + return [[store.runs.get(params[0])].filter(Boolean)]; + } + if (text.startsWith('UPDATE h5_goal_checkpoints') && text.includes("SET status = 'running'")) { + const checkpoint = store.checkpoints.get(params[3]); + checkpoint.status = 'running'; + checkpoint.agent_run_id = params[0]; + checkpoint.started_at = params[1]; + checkpoint.updated_at = params[2]; + return [{ affectedRows: 1 }]; + } + if (text.startsWith('UPDATE h5_goal_runs') && text.includes("status = 'active'")) { + const goal = store.goals.get(params[2]); + goal.current_checkpoint_id = params[0]; + goal.status = 'active'; + goal.updated_at = params[1]; + return [{ affectedRows: 1 }]; + } + if (text.includes("status = 'approved'") && text.includes('h5_goal_checkpoints')) { + const checkpoint = store.checkpoints.get(params[4]); + checkpoint.status = 'approved'; + checkpoint.output_summary = params[0]; + checkpoint.completed_at = params[1]; + checkpoint.approved_at = params[2]; + checkpoint.updated_at = params[3]; + return [{ affectedRows: 1 }]; + } + if (text.includes('FROM h5_goal_checkpoints') && text.includes("status IN ('pending', 'failed')")) { + return [[]]; + } + if (text.includes("SET status = 'completed'") && text.includes('h5_goal_runs')) { + const goal = store.goals.get(params[3]); + goal.status = 'completed'; + goal.completed_at = params[2]; + goal.updated_at = params[2]; + return [{ affectedRows: 1 }]; + } + if (text.includes('FROM h5_goal_runs WHERE id = ? AND user_id = ?')) { + const goal = store.goals.get(params[0]); + if (!goal || goal.user_id !== params[1]) return [[]]; + return [[goal]]; + } + if (text.includes('FROM h5_goal_checkpoints WHERE goal_run_id = ?')) { + return [[...store.checkpoints.values()].filter((item) => item.goal_run_id === params[0])]; + } + if (text.includes('FROM h5_goal_checkpoints c') && text.includes('INNER JOIN')) { + const checkpoint = store.checkpoints.get(params[0]); + const goal = store.goals.get(checkpoint?.goal_run_id); + if (!checkpoint || !goal) return [[]]; + return [[{ ...checkpoint, user_id: goal.user_id, goal_status: goal.status }]]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; + + const service = createGoalRunService({ pool, now: () => 2000 }); + const started = await service.startNextCheckpoint({ userId: 'user-1', goalRunId: 'goal-1' }); + assert.equal(started.checkpointId, 'cp-1'); + assert.equal(store.checkpoints.get('cp-1').status, 'running'); + + const completed = await service.onAgentRunCompleted({ + agentRunId: 'run-1', + status: 'succeeded', + outputSummary: '完成第一阶段', + }); + assert.equal(completed.handled, true); + assert.equal(completed.goalCompleted, true); + assert.equal(store.checkpoints.get('cp-1').status, 'approved'); + assert.equal(store.goals.get('goal-1').status, 'completed'); +}); + +test('onAgentRunCompleted requires approval between multi-checkpoint stages', async () => { + const store = { + goals: new Map([ + ['goal-2', { + id: 'goal-2', + user_id: 'user-1', + title: '多阶段任务', + intent_summary: '两阶段', + status: 'active', + source_channel: 'h5', + current_checkpoint_id: 'cp-1', + context_json: null, + memory_snapshot_json: null, + created_at: 1000, + updated_at: 1000, + completed_at: null, + priority: 5, + source_session_id: null, + source_message_id: null, + }], + ]), + checkpoints: new Map([ + ['cp-1', { + id: 'cp-1', + goal_run_id: 'goal-2', + sequence: 1, + title: '调研', + description: null, + status: 'running', + agent_run_id: 'run-1', + output_summary: null, + output_artifact_ids: null, + user_feedback: null, + approved_at: null, + created_at: 1000, + updated_at: 1000, + started_at: 1000, + completed_at: null, + }], + ['cp-2', { + id: 'cp-2', + goal_run_id: 'goal-2', + sequence: 2, + title: '输出草案', + description: null, + status: 'pending', + agent_run_id: null, + output_summary: null, + output_artifact_ids: null, + user_feedback: null, + approved_at: null, + created_at: 1000, + updated_at: 1000, + started_at: null, + completed_at: null, + }], + ]), + runs: new Map([ + ['run-1', { + id: 'run-1', + goal_run_id: 'goal-2', + goal_checkpoint_id: 'cp-1', + status: 'succeeded', + }], + ]), + }; + const pool = { + async query(sql, params) { + const text = String(sql); + if (text.includes('FROM h5_agent_runs') && text.includes('WHERE id = ?')) { + return [[store.runs.get(params[0])].filter(Boolean)]; + } + if (text.includes('COUNT(*) AS total')) { + return [[{ total: store.checkpoints.size }]]; + } + if (text.includes('FROM h5_goal_checkpoints') && text.includes("status IN ('pending', 'failed')")) { + return [[store.checkpoints.get('cp-2')].filter((item) => ['pending', 'failed'].includes(item.status))]; + } + if (text.includes("SET status = 'awaiting_approval'")) { + store.checkpoints.get(params[3]).status = 'awaiting_approval'; + return [{ affectedRows: 1 }]; + } + if (text.includes("SET status = 'awaiting_user'")) { + store.goals.get('goal-2').status = 'awaiting_user'; + return [{ affectedRows: 1 }]; + } + if (text.includes('FROM h5_goal_checkpoints c') && text.includes('INNER JOIN')) { + const checkpoint = store.checkpoints.get(params[0]); + const goal = store.goals.get(checkpoint?.goal_run_id); + return [[{ ...checkpoint, user_id: goal.user_id, goal_status: goal.status }]]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; + + const service = createGoalRunService({ pool, now: () => 2000 }); + const completed = await service.onAgentRunCompleted({ + agentRunId: 'run-1', + status: 'succeeded', + outputSummary: '调研完成', + }); + assert.equal(completed.handled, true); + assert.equal(completed.awaitingApproval, true); + assert.equal(store.goals.get('goal-2').status, 'awaiting_user'); + assert.equal(store.checkpoints.get('cp-1').status, 'awaiting_approval'); + assert.equal(store.checkpoints.get('cp-2').status, 'pending'); +}); diff --git a/ops/src/App.tsx b/ops/src/App.tsx index ef3257e..abaca30 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -16,6 +16,7 @@ import { WechatPage } from './pages/admin/WechatPage'; import { SystemPolicyPage } from './pages/admin/SystemPolicyPage'; import { OrchestratorPage } from './pages/admin/OrchestratorPage'; import { LlmProvidersPage } from './pages/admin/LlmProvidersPage'; +import { GoalRunPage } from './pages/admin/GoalRunPage'; export function App() { return ( @@ -52,6 +53,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 3fc3b87..fef23d4 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -893,3 +893,126 @@ export async function patchMemoryV2Config(patch: Record) { export async function fetchMemoryV2Runtime() { return adminFetch('/admin-api/memory-v2/runtime'); } +export type GoalRunRuntimeState = { + enabled: boolean; + canaryUserIds: string[]; + canaryMode: boolean; +}; + +export type GoalRunSummaryResponse = { + counts: Record; + runtime: GoalRunRuntimeState; +}; + +export type GoalRunListItem = { + id: string; + userId: string; + username: string | null; + title: string; + intentSummary: string; + status: string; + priority: number; + sourceChannel: string; + sourceSessionId: string | null; + currentCheckpointId: string | null; + checkpointCount: number; + activeAgentRunCount: number; + createdAt: number; + updatedAt: number; + completedAt: number | null; +}; + +export type GoalRunCheckpointRow = { + 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 GoalRunAgentRunRow = { + id: string; + status: string; + requestId: string; + goalCheckpointId: string | null; + createdAt: number; + updatedAt: number; + completedAt: number | null; +}; + +export type GoalRunDetail = { + id: string; + userId: string; + username: string | null; + title: string; + intentSummary: string; + status: string; + priority: number; + sourceChannel: string; + sourceSessionId: string | null; + sourceMessageId: string | null; + currentCheckpointId: string | null; + context: unknown; + memorySnapshot: unknown; + createdAt: number; + updatedAt: number; + completedAt: number | null; + checkpoints: GoalRunCheckpointRow[]; + agentRuns: GoalRunAgentRunRow[]; + canaryEnabled: boolean; +}; + +export async function fetchGoalRunSummary() { + return adminFetch('/admin-api/goal-runs/summary'); +} + +export async function fetchGoalRuns(params?: { + status?: string; + userId?: string; + limit?: number; + offset?: number; +}) { + const query = new URLSearchParams(); + if (params?.status) query.set('status', params.status); + if (params?.userId) query.set('userId', params.userId); + if (params?.limit != null) query.set('limit', String(params.limit)); + if (params?.offset != null) query.set('offset', String(params.offset)); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return adminFetch<{ items: GoalRunListItem[]; status: string | null; limit: number; offset: number }>( + `/admin-api/goal-runs${suffix}`, + ); +} + +export async function fetchGoalRunDetail(goalRunId: string) { + return adminFetch<{ goal: GoalRunDetail }>(`/admin-api/goal-runs/${encodeURIComponent(goalRunId)}`); +} + +export async function approveGoalRunCheckpoint( + goalRunId: string, + checkpointId: string, + feedback?: string | null, +) { + return adminFetch<{ ok: true; goal: GoalRunDetail }>( + `/admin-api/goal-runs/${encodeURIComponent(goalRunId)}/checkpoints/${encodeURIComponent(checkpointId)}/approve`, + { + method: 'POST', + body: JSON.stringify({ feedback: feedback ?? null }), + }, + ); +} + +export async function cancelGoalRun(goalRunId: string) { + return adminFetch<{ ok: true; goal: GoalRunDetail }>( + `/admin-api/goal-runs/${encodeURIComponent(goalRunId)}/cancel`, + { method: 'POST', body: JSON.stringify({}) }, + ); +} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 1ff01d7..7a773fa 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -9,6 +9,7 @@ const links = [ { to: '/admin/policy', label: '策略中心' }, { to: '/admin/orchestrator', label: '任务编排' }, { to: '/admin/llm', label: '统一大模型' }, + { to: '/admin/goal-runs', label: 'Goal Run' }, ]; export function AdminLayout() { diff --git a/ops/src/pages/admin/GoalRunPage.tsx b/ops/src/pages/admin/GoalRunPage.tsx new file mode 100644 index 0000000..26a8189 --- /dev/null +++ b/ops/src/pages/admin/GoalRunPage.tsx @@ -0,0 +1,390 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + approveGoalRunCheckpoint, + cancelGoalRun, + fetchGoalRunDetail, + fetchGoalRunSummary, + fetchGoalRuns, + type GoalRunCheckpointRow, + type GoalRunDetail, + type GoalRunListItem, + type GoalRunSummaryResponse, +} from '../../api/admin'; + +const STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'active', label: 'active' }, + { value: 'awaiting_user', label: 'awaiting_user' }, + { value: 'paused', label: 'paused' }, + { value: 'completed', label: 'completed' }, + { value: 'failed', label: 'failed' }, + { value: 'cancelled', label: 'cancelled' }, +]; + +function formatTime(value: number | null | undefined) { + if (!value) return '—'; + return new Date(value).toLocaleString('zh-CN', { hour12: false }); +} + +function statusBadge(status: string) { + const colors: Record = { + active: '#1f6feb', + awaiting_user: '#b78103', + paused: '#68716c', + completed: '#1a7f37', + failed: '#cf222e', + cancelled: '#68716c', + running: '#1f6feb', + pending: '#68716c', + approved: '#1a7f37', + }; + return ( + + {status} + + ); +} + +function CheckpointList({ + checkpoints, + onApprove, + actionBusy, +}: { + checkpoints: GoalRunCheckpointRow[]; + onApprove: (checkpointId: string) => void; + actionBusy: string | null; +}) { + if (!checkpoints.length) return

暂无阶段。

; + return ( +
+ {checkpoints.map((checkpoint) => ( +
+
+ {checkpoint.sequence}. {checkpoint.title} + {statusBadge(checkpoint.status)} +
+ {checkpoint.description && ( +

{checkpoint.description}

+ )} + {checkpoint.outputSummary && ( +

输出:{checkpoint.outputSummary}

+ )} +

+ agent_run={checkpoint.agentRunId ?? '—'} · 更新 {formatTime(checkpoint.updatedAt)} +

+ {checkpoint.status === 'awaiting_approval' && ( +
+ +
+ )} +
+ ))} +
+ ); +} + +export function GoalRunPage() { + const [summary, setSummary] = useState(null); + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [statusFilter, setStatusFilter] = useState(''); + const [userFilter, setUserFilter] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [actionBusy, setActionBusy] = useState(null); + + const reloadDetail = useCallback(async (goalRunId: string) => { + const result = await fetchGoalRunDetail(goalRunId); + setDetail(result.goal); + }, []); + + const reloadList = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [summaryRes, listRes] = await Promise.all([ + fetchGoalRunSummary(), + fetchGoalRuns({ + status: statusFilter || undefined, + userId: userFilter.trim() || undefined, + limit: 50, + }), + ]); + setSummary(summaryRes); + setItems(listRes.items); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, [statusFilter, userFilter]); + + useEffect(() => { + void reloadList(); + }, [reloadList]); + + useEffect(() => { + if (!selectedId) { + setDetail(null); + return; + } + let cancelled = false; + setDetailLoading(true); + void fetchGoalRunDetail(selectedId) + .then((result) => { + if (!cancelled) setDetail(result.goal); + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : '读取详情失败'); + } + }) + .finally(() => { + if (!cancelled) setDetailLoading(false); + }); + return () => { + cancelled = true; + }; + }, [selectedId]); + + const approveCheckpoint = async (checkpointId: string) => { + if (!selectedId) return; + setActionBusy(`approve:${checkpointId}`); + setError(null); + try { + await approveGoalRunCheckpoint(selectedId, checkpointId); + await reloadDetail(selectedId); + await reloadList(); + } catch (err) { + setError(err instanceof Error ? err.message : '确认阶段失败'); + } finally { + setActionBusy(null); + } + }; + + const cancelSelectedGoal = async () => { + if (!selectedId) return; + setActionBusy('cancel'); + setError(null); + try { + await cancelGoalRun(selectedId); + await reloadDetail(selectedId); + await reloadList(); + } catch (err) { + setError(err instanceof Error ? err.message : '取消目标失败'); + } finally { + setActionBusy(null); + } + }; + + if (loading && !summary) return

加载中…

; + + return ( +
+ {error &&

{error}

} + +
+
+
+

Goal Run

+

+ 长期目标任务与 checkpoint 生命周期(memindadm 只读观测) +

+
+ +
+ {summary && ( +
+

+ 功能开关: + {summary.runtime.enabled ? '已启用' : '未启用'} + {summary.runtime.canaryMode + ? ` · Canary 用户 ${summary.runtime.canaryUserIds.length} 个` + : ' · 全员'} +

+
+ {Object.entries(summary.counts).map(([status, count]) => ( +
+

{status}

+ {count} +
+ ))} +
+
+ )} +
+ +
+
+ + +
+ +
+
+ +

目标列表({items.length})

+ {items.length === 0 ? ( +

暂无 Goal Run 记录。

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ + {selectedId && ( +
+

目标详情

+ {detailLoading && !detail ? ( +

加载详情…

+ ) : detail ? ( +
+
+

{detail.title}

+

{detail.intentSummary}

+

+ id={detail.id} · user={detail.username ?? detail.userId} + · canary={detail.canaryEnabled ? '是' : '否'} +

+
+
+
+ {!['completed', 'cancelled'].includes(detail.status) && ( + + )} +
+

Checkpoints

+ void approveCheckpoint(checkpointId)} + actionBusy={actionBusy} + /> +
+
+

关联 Agent Runs

+ {detail.agentRuns.length === 0 ? ( +

暂无关联 run。

+ ) : ( +
+ {detail.agentRuns.map((run) => ( +
+ {run.id} + {statusBadge(run.status)} + + checkpoint={run.goalCheckpointId ?? '—'} · {formatTime(run.updatedAt)} + +
+ ))} +
+ )} +
+
+ ) : ( +

未找到详情。

+ )} +
+ )} +
+ ); +} diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx index 13c97e1..3aad765 100644 --- a/ops/src/pages/admin/SummaryPage.tsx +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -71,6 +71,9 @@ export function SummaryPage() { 统一大模型 / H5 路由 + + Goal Run 观测 + 进入用户管理 diff --git a/package.json b/package.json index 0ffe2a1..73b97f1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "check:mindspace-cover": "node scripts/check-mindspace-cover.mjs", "demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs", "audit:conversation-packages": "node scripts/audit-conversation-packages.mjs", + "verify:goal-run-service": "node --test goal-run-service.test.mjs goal-run-intent.test.mjs goal-run-resolve.test.mjs goal-run-gateway.test.mjs goal-run-policy.test.mjs goal-run-admin-ops.test.mjs goal-run-awaiting.test.mjs server/portal-goal-run-routes.test.mjs", + "verify:goal-run-local": "node scripts/verify-goal-run-local.mjs", + "verify:goal-run-http-local": "node scripts/verify-goal-run-http-local.mjs", "trace:mindspace-artifact": "node scripts/trace-mindspace-artifact.mjs", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", "check:memory-v2": "node scripts/check-memory-v2-health.mjs", diff --git a/scripts/goal-run-verify-lib.mjs b/scripts/goal-run-verify-lib.mjs new file mode 100644 index 0000000..9aad24d --- /dev/null +++ b/scripts/goal-run-verify-lib.mjs @@ -0,0 +1,37 @@ +export function parseCanaryUserIds(raw = process.env.GOAL_RUN_CANARY_USER_IDS) { + return String(raw ?? '') + .split(/[,;\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export async function resolveCanaryVerifyUser(pool, { + usernameFallback = process.env.VERIFY_GOAL_RUN_USER + ?? process.env.VERIFY_LLM_ROUTER_USER + ?? 'john2', +} = {}) { + const canaryIds = parseCanaryUserIds(); + const preferredId = canaryIds[0] ?? null; + if (preferredId) { + const [rows] = await pool.query( + 'SELECT id, username FROM h5_users WHERE id = ? LIMIT 1', + [preferredId], + ); + if (rows[0]?.id) { + return { + userId: String(rows[0].id), + username: String(rows[0].username ?? usernameFallback), + }; + } + } + + const [rows] = await pool.query( + 'SELECT id, username FROM h5_users WHERE username = ? LIMIT 1', + [usernameFallback], + ); + if (!rows[0]?.id) return null; + return { + userId: String(rows[0].id), + username: String(rows[0].username ?? usernameFallback), + }; +} diff --git a/scripts/verify-goal-run-http-local.mjs b/scripts/verify-goal-run-http-local.mjs new file mode 100644 index 0000000..7459b0e --- /dev/null +++ b/scripts/verify-goal-run-http-local.mjs @@ -0,0 +1,363 @@ +#!/usr/bin/env node +/** + * HTTP-focused Goal Run verify against local Portal (no 103). + * Requires Portal already running with GOAL_RUN_* enabled for canary user. + * + * Usage: + * GOAL_RUN_ENABLED=1 GOAL_RUN_CANARY_USER_IDS= node scripts/verify-goal-run-http-local.mjs + */ +import crypto from 'node:crypto'; +import { loadH5Environment } from './load-env.mjs'; +import { createDbPool, isDatabaseConfigured } from '../db.mjs'; +import { createGoalRunService } from '../goal-run-service.mjs'; +import { isGoalRunEnabledForUser } from '../goal-run-intent.mjs'; +import { resolveCanaryVerifyUser } from './goal-run-verify-lib.mjs'; + +loadH5Environment(import.meta.dirname); + +const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; +const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888'; + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function pass(label, detail = '') { + console.log(`PASS ${label}${detail ? `: ${detail}` : ''}`); +} + +function fail(label, detail = '') { + console.error(`FAIL ${label}${detail ? `: ${detail}` : ''}`); + process.exitCode = 1; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPortal() { + const started = Date.now(); + while (Date.now() - started < 60_000) { + try { + const response = await fetch(`${PORTAL}/api/status`, { signal: AbortSignal.timeout(2000) }); + if (response.ok) return; + } catch { + // retry + } + await sleep(1000); + } + throw new Error(`Portal ${PORTAL} 未在 60s 内就绪`); +} + +async function login(pool) { + const verifyUser = await resolveCanaryVerifyUser(pool); + if (!verifyUser) { + throw new Error('cannot resolve canary verify user'); + } + const { createUserAuth } = await import('../user-auth.mjs'); + const auth = createUserAuth(pool); + const result = await auth.login({ + username: verifyUser.username, + password: PASSWORD, + ip: '127.0.0.1', + }); + if (!result.ok) { + throw new Error(`login failed for ${verifyUser.username}: ${result.message ?? 'unknown'}`); + } + return { + token: result.token, + userId: verifyUser.userId, + username: verifyUser.username, + }; +} + +async function apiFetch(path, { token, method = 'GET', body = null } = {}) { + const response = await fetch(`${PORTAL}/api${path}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + Cookie: `tkmind_user_session=${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15_000), + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function portalFetch(path, { token, method = 'GET', body = null } = {}) { + const response = await fetch(`${PORTAL}${path}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + Cookie: `tkmind_user_session=${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15_000), + }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function startAgentSession(token) { + const started = await apiFetch('/agent/start', { + token, + method: 'POST', + body: {}, + }); + const sessionId = started.payload?.session?.id ?? started.payload?.id ?? null; + if (!started.response.ok || !sessionId) { + throw new Error(`agent/start failed: ${started.response.status}`); + } + return String(sessionId); +} + +async function seedAwaitingGoal(pool, userId) { + const service = createGoalRunService({ pool }); + await service.ensureSchema(); + const goal = await service.createGoalRun({ + userId, + title: `[verify-http] ${new Date().toISOString()}`, + intentSummary: 'HTTP verify awaiting approval', + sourceChannel: 'api', + checkpoints: [ + { title: '调研', description: '收集信息' }, + { title: '输出', description: '形成草案' }, + ], + }); + const started = await service.startNextCheckpoint({ userId, goalRunId: goal.id }); + const runId = crypto.randomUUID(); + const now = Date.now(); + await pool.query( + `INSERT INTO h5_agent_runs + (id, user_id, agent_session_id, goal_run_id, goal_checkpoint_id, request_id, + status, attempts, user_message_json, created_at, updated_at, completed_at) + VALUES (?, ?, NULL, ?, ?, ?, 'succeeded', 1, '{}', ?, ?, ?)`, + [runId, userId, goal.id, started.checkpointId, `verify-http-${now}`, now, now, now], + ); + await service.attachAgentRunToCheckpoint({ checkpointId: started.checkpointId, agentRunId: runId }); + const completed = await service.onAgentRunCompleted({ + agentRunId: runId, + status: 'succeeded', + outputSummary: 'HTTP verify stage-1 done', + }); + if (!completed?.awaitingApproval) { + throw new Error(`expected awaitingApproval, got ${JSON.stringify(completed)}`); + } + return { goal, firstCheckpointId: started.checkpointId, runId }; +} + +async function cleanup(pool, { goalId, runId, extraRunIds = [] }) { + for (const id of extraRunIds) { + if (id) await pool.query('DELETE FROM h5_agent_runs WHERE id = ?', [id]); + } + if (runId) await pool.query('DELETE FROM h5_agent_runs WHERE id = ?', [runId]); + if (goalId) await pool.query('DELETE FROM h5_goal_runs WHERE id = ?', [goalId]); +} + +async function main() { + if (!envFlag(process.env.GOAL_RUN_ENABLED, false)) { + fail('env', 'set GOAL_RUN_ENABLED=1 before verify'); + return; + } + if (!String(process.env.GOAL_RUN_CANARY_USER_IDS ?? '').trim()) { + fail('env', 'set GOAL_RUN_CANARY_USER_IDS before verify'); + return; + } + if (!isDatabaseConfigured()) { + fail('database', 'local MySQL not configured'); + return; + } + + const pool = createDbPool(); + let seeded = null; + const extraRunIds = []; + try { + await waitForPortal(); + pass('portal', PORTAL); + + const { token, userId, username } = await login(pool); + if (!isGoalRunEnabledForUser(userId, process.env)) { + fail('canary', `${userId} not in GOAL_RUN_CANARY_USER_IDS`); + return; + } + pass('login', `${username} (${userId})`); + + const authStatus = await portalFetch('/auth/status', { token }); + if (!authStatus.payload?.goalRun?.enabled) { + fail('auth.status', 'goalRun.enabled missing — restart pnpm dev with GOAL_RUN_*'); + return; + } + pass('auth.status', 'goalRun.enabled=true'); + + const list = await apiFetch('/goals?status=awaiting_user', { token }); + if (list.response.status === 503) { + fail('http.list', 'Goal Run disabled in running Portal — restart pnpm dev with GOAL_RUN_*'); + return; + } + if (list.response.status === 403) { + fail('http.list', 'canary mismatch in running Portal — restart pnpm dev'); + return; + } + if (!list.response.ok) { + fail('http.list', `${list.response.status}`); + return; + } + pass('http.list', `${list.payload.goals?.length ?? 0} awaiting`); + + const create = await apiFetch('/goals', { + token, + method: 'POST', + body: { + title: `[verify-http-create] ${Date.now()}`, + intentSummary: 'HTTP create smoke', + checkpoints: [{ title: '一步', description: '单阶段' }], + }, + }); + if (!create.response.ok) { + fail('http.create', `${create.response.status} ${JSON.stringify(create.payload)}`); + return; + } + const createdGoalId = create.payload.goal?.id; + pass('http.create', createdGoalId); + await pool.query('DELETE FROM h5_goal_runs WHERE id = ?', [createdGoalId]); + + seeded = await seedAwaitingGoal(pool, userId); + pass('seed.awaiting', seeded.goal.id); + + const awaitingList = await apiFetch('/goals?status=awaiting_user', { token }); + const found = (awaitingList.payload.goals ?? []).some((item) => item.id === seeded.goal.id); + if (!found) { + fail('http.awaiting', 'seeded goal not listed'); + return; + } + pass('http.awaiting', seeded.goal.id); + + const detail = await apiFetch(`/goals/${encodeURIComponent(seeded.goal.id)}`, { token }); + const awaitingCheckpoint = (detail.payload.goal?.checkpoints ?? []).find( + (item) => item.status === 'awaiting_approval', + ); + if (!awaitingCheckpoint) { + fail('http.detail', 'missing awaiting_approval checkpoint'); + return; + } + pass('http.detail', awaitingCheckpoint.id); + + const sessionId = await startAgentSession(token); + pass('http.session', sessionId); + + const approve = await apiFetch( + `/goals/${encodeURIComponent(seeded.goal.id)}/checkpoints/${encodeURIComponent(awaitingCheckpoint.id)}/approve`, + { + token, + method: 'POST', + body: { + feedback: 'verify-http approve', + session_id: sessionId, + request_id: crypto.randomUUID(), + }, + }, + ); + if (!approve.response.ok) { + fail('http.approve', `${approve.response.status} ${JSON.stringify(approve.payload)}`); + return; + } + pass('http.approve', approve.payload.goal?.status ?? 'ok'); + if (!approve.payload.run?.id) { + fail('http.approve.dispatch', 'missing auto-dispatched run'); + return; + } + extraRunIds.push(approve.payload.run.id); + pass('http.approve.dispatch', approve.payload.run.id); + + const [dispatchRows] = await pool.query( + 'SELECT goal_run_id, goal_checkpoint_id FROM h5_agent_runs WHERE id = ? LIMIT 1', + [approve.payload.run.id], + ); + if (String(dispatchRows[0]?.goal_run_id) !== seeded.goal.id) { + fail('http.approve.dispatch.binding', `goal_run_id=${dispatchRows[0]?.goal_run_id}`); + return; + } + pass('http.approve.dispatch.binding', dispatchRows[0]?.goal_checkpoint_id ?? 'bound'); + + const bindSessionId = await startAgentSession(token); + const bindGoal = await apiFetch('/goals', { + token, + method: 'POST', + body: { + title: `[verify-http-bind] ${Date.now()}`, + intentSummary: 'HTTP verify goal_run_id binding', + checkpoints: [ + { title: '执行', description: '单阶段绑定 smoke' }, + ], + }, + }); + if (!bindGoal.response.ok || !bindGoal.payload.goal?.id) { + fail('http.bind.create', `${bindGoal.response.status}`); + return; + } + const bindGoalId = bindGoal.payload.goal.id; + pass('http.bind.create', bindGoalId); + + const agentRun = await apiFetch('/agent/runs', { + token, + method: 'POST', + body: { + session_id: bindSessionId, + request_id: crypto.randomUUID(), + goal_run_id: bindGoalId, + user_message: { + id: crypto.randomUUID(), + role: 'user', + content: [{ type: 'text', text: '开始执行目标' }], + metadata: { userVisible: true, displayText: '开始执行目标' }, + }, + }, + }); + if (!agentRun.response.ok && agentRun.response.status !== 202) { + fail('http.agentRun', `${agentRun.response.status} ${JSON.stringify(agentRun.payload)}`); + return; + } + const runId = agentRun.payload.run?.id; + if (!runId) { + fail('http.agentRun', 'missing run id'); + return; + } + extraRunIds.push(runId); + pass('http.agentRun', runId); + + const [rows] = await pool.query( + 'SELECT goal_run_id, goal_checkpoint_id FROM h5_agent_runs WHERE id = ? LIMIT 1', + [runId], + ); + if (String(rows[0]?.goal_run_id) !== bindGoalId) { + fail('http.agentRun.binding', `goal_run_id=${rows[0]?.goal_run_id}`); + return; + } + pass('http.agentRun.binding', rows[0]?.goal_checkpoint_id ?? 'bound'); + await pool.query('DELETE FROM h5_goal_runs WHERE id = ?', [bindGoalId]); + } catch (err) { + fail('fatal', err instanceof Error ? err.message : String(err)); + } finally { + if (seeded) { + await cleanup(pool, { + goalId: seeded.goal.id, + runId: seeded.runId, + extraRunIds, + }).catch(() => {}); + } + await pool.end?.().catch(() => {}); + } + + if (process.exitCode) { + console.error('\nGoal Run HTTP local verify failed.'); + } else { + console.log('\nGoal Run HTTP local verify passed.'); + } +} + +main().catch((err) => { + fail('fatal', err instanceof Error ? err.message : String(err)); +}); diff --git a/scripts/verify-goal-run-local.mjs b/scripts/verify-goal-run-local.mjs new file mode 100644 index 0000000..b4f0d51 --- /dev/null +++ b/scripts/verify-goal-run-local.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Local Goal Run MVP verify (no 103 / no production). + * - Service-layer lifecycle against local MySQL when configured + * - Optional HTTP smoke when local Portal is already running + * + * Usage: + * GOAL_RUN_ENABLED=1 GOAL_RUN_CANARY_USER_IDS= node scripts/verify-goal-run-local.mjs + */ +import crypto from 'node:crypto'; +import { loadH5Environment } from './load-env.mjs'; +import { createDbPool, isDatabaseConfigured } from '../db.mjs'; +import { createGoalRunService } from '../goal-run-service.mjs'; +import { isGoalRunEnabledForUser } from '../goal-run-intent.mjs'; +import { resolveCanaryVerifyUser } from './goal-run-verify-lib.mjs'; + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +loadH5Environment(import.meta.dirname); + +const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; +const USERNAME = process.env.VERIFY_GOAL_RUN_USER ?? process.env.VERIFY_LLM_ROUTER_USER ?? 'john2'; +const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888'; + +function pass(label, detail = '') { + console.log(`PASS ${label}${detail ? `: ${detail}` : ''}`); +} + +function fail(label, detail = '') { + console.error(`FAIL ${label}${detail ? `: ${detail}` : ''}`); + process.exitCode = 1; +} + +async function resolveVerifyUserId(pool) { + const verifyUser = await resolveCanaryVerifyUser(pool, { usernameFallback: USERNAME }); + return verifyUser?.userId ?? null; +} + +async function verifyServiceLifecycle(pool, userId) { + const service = createGoalRunService({ pool }); + await service.ensureSchema(); + + const goal = await service.createGoalRun({ + userId, + title: `[verify] Goal Run ${new Date().toISOString()}`, + intentSummary: '本地 verify:分阶段完成 smoke 任务', + sourceChannel: 'api', + checkpoints: [ + { title: '调研', description: '收集信息' }, + { title: '输出', description: '形成草案' }, + ], + }); + if (!goal?.id || !goal.checkpoints?.length) { + fail('service.createGoalRun', 'missing goal or checkpoints'); + return null; + } + pass('service.createGoalRun', goal.id); + + const started = await service.startNextCheckpoint({ + userId, + goalRunId: goal.id, + agentRunId: null, + }); + if (!started?.checkpointId) { + fail('service.startNextCheckpoint', 'missing checkpoint'); + return goal; + } + pass('service.startNextCheckpoint', started.checkpointId); + + const runId = crypto.randomUUID(); + const now = Date.now(); + await pool.query( + `INSERT INTO h5_agent_runs + (id, user_id, agent_session_id, goal_run_id, goal_checkpoint_id, request_id, + status, attempts, user_message_json, created_at, updated_at, completed_at) + VALUES (?, ?, NULL, ?, ?, ?, 'succeeded', 1, '{}', ?, ?, ?)`, + [runId, userId, goal.id, started.checkpointId, `verify-goal-${now}`, now, now, now], + ); + await service.attachAgentRunToCheckpoint({ + checkpointId: started.checkpointId, + agentRunId: runId, + }); + + const completed = await service.onAgentRunCompleted({ + agentRunId: runId, + status: 'succeeded', + outputSummary: 'verify smoke completed', + }); + if (!completed?.handled) { + fail('service.onAgentRunCompleted', completed?.reason ?? 'not handled'); + return goal; + } + pass('service.onAgentRunCompleted', completed.goalCompleted + ? 'goal completed' + : completed.awaitingApproval + ? 'awaiting approval' + : 'checkpoint advanced'); + + if (goal.checkpoints.length > 1 && completed.goalCompleted) { + fail('service.multiCheckpoint', 'expected goal to stay open until final checkpoint'); + return goal; + } + if (goal.checkpoints.length > 1 && completed.awaitingApproval) { + const firstCheckpoint = goal.checkpoints[0]; + const approved = await service.approveCheckpoint({ + userId, + goalRunId: goal.id, + checkpointId: firstCheckpoint.id, + feedback: 'verify-local approve', + }); + pass('service.approveCheckpoint', approved.status); + const resumed = await service.startNextCheckpoint({ userId, goalRunId: goal.id }); + pass('service.resumeNextCheckpoint', resumed.checkpointId); + } else if (goal.checkpoints.length > 1 && completed.nextCheckpointId) { + pass('service.multiCheckpoint', `next=${completed.nextCheckpointId}`); + const resumed = await service.startNextCheckpoint({ + userId, + goalRunId: goal.id, + }); + pass('service.resumeNextCheckpoint', resumed.checkpointId); + } + + const finalGoal = await service.getGoalRun({ userId, goalRunId: goal.id }); + if (!finalGoal) { + fail('service.getGoalRun', 'missing after completion'); + return goal; + } + pass('service.getGoalRun', `status=${finalGoal.status}`); + + await pool.query('DELETE FROM h5_agent_runs WHERE id = ?', [runId]); + await pool.query('DELETE FROM h5_goal_runs WHERE id = ?', [goal.id]); + pass('service.cleanup', 'removed verify goal/run'); + return goal; +} + +async function loginForHttp(pool) { + const verifyUser = await resolveCanaryVerifyUser(pool, { usernameFallback: USERNAME }); + if (!verifyUser) return null; + const { createUserAuth } = await import('../user-auth.mjs'); + const auth = createUserAuth(pool); + const result = await auth.login({ + username: verifyUser.username, + password: PASSWORD, + ip: '127.0.0.1', + }); + if (!result.ok) return null; + return result.token; +} + +async function verifyHttpApi(token, userId) { + try { + const health = await fetch(`${PORTAL}/api/goals`, { + headers: { Cookie: `tkmind_user_session=${token}` }, + signal: AbortSignal.timeout(3000), + }); + if (health.status === 503) { + console.log('SKIP http.api (Goal Run disabled in running server env)'); + return; + } + if (health.status === 403) { + console.log('SKIP http.api (running server has Goal Run disabled or canary mismatch — restart pnpm dev after setting GOAL_RUN_* in .env)'); + return; + } + if (!health.ok) { + fail('http.api.list', `GET /api/goals -> ${health.status}`); + return; + } + pass('http.api.list', `${health.status}`); + + const createRes = await fetch(`${PORTAL}/api/goals`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `tkmind_user_session=${token}`, + }, + body: JSON.stringify({ + title: `[verify-http] ${Date.now()}`, + intentSummary: 'HTTP verify goal', + }), + }); + const createBody = await createRes.json().catch(() => ({})); + if (!createRes.ok) { + fail('http.api.create', `${createRes.status} ${JSON.stringify(createBody)}`); + return; + } + pass('http.api.create', createBody.goal?.id ?? 'ok'); + } catch (err) { + console.log(`SKIP http.api (${err instanceof Error ? err.message : err})`); + } +} + +async function main() { + if (!envFlag(process.env.GOAL_RUN_ENABLED, false)) { + fail('env', 'set GOAL_RUN_ENABLED=1 before verify'); + return; + } + if (!String(process.env.GOAL_RUN_CANARY_USER_IDS ?? '').trim()) { + fail('env', 'set GOAL_RUN_CANARY_USER_IDS before verify'); + return; + } + pass('env', 'Goal Run feature flag enabled'); + + if (!isDatabaseConfigured()) { + fail('database', 'local MySQL not configured'); + return; + } + + const pool = createDbPool(); + try { + const userId = await resolveVerifyUserId(pool); + if (!userId) { + fail('user', `cannot resolve verify user (${USERNAME})`); + return; + } + if (!isGoalRunEnabledForUser(userId, process.env)) { + fail('canary', `user ${userId} not in GOAL_RUN_CANARY_USER_IDS`); + return; + } + pass('canary', userId); + + await verifyServiceLifecycle(pool, userId); + + const token = await loginForHttp(pool); + if (token) { + await verifyHttpApi(token, userId); + } else { + console.log('SKIP http.api (login failed)'); + } + } finally { + await pool.end?.().catch(() => {}); + } + + if (process.exitCode) { + console.error('\nGoal Run local verify failed.'); + } else { + console.log('\nGoal Run local verify passed.'); + } +} + +main().catch((err) => { + fail('fatal', err instanceof Error ? err.message : String(err)); +}); diff --git a/server.mjs b/server.mjs index ef1bbad..c401341 100644 --- a/server.mjs +++ b/server.mjs @@ -69,6 +69,7 @@ import { createPortalAuthSessionHelpers } from './server/portal-auth-session-hel import { createPortalSessionCoordinator } from './server/portal-session-coordinator.mjs'; import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs'; import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs'; +import { attachPortalGoalRunRoutes } from './server/portal-goal-run-routes.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { @@ -253,6 +254,7 @@ let sessionStreamStore = null; let tkmindProxy = null; let agentRunGateway = null; +let goalRunService = null; const { ownsAgentSession, unregisterAgentSessionForUser, @@ -511,6 +513,7 @@ async function bootstrapUserAuth() { toolGateway = gatewayServices.toolGateway; agentRunGateway = gatewayServices.agentRunGateway; + goalRunService = gatewayServices.goalRunService; const integrationServices = await bootstrapPortalIntegrationServices({ pool, @@ -602,6 +605,11 @@ async function resolveAgentCodeRunForClient(userId) { } } +async function resolveGoalRunForClient(userId) { + const { isGoalRunEnabledForUser } = await import('./goal-run-intent.mjs'); + return { enabled: isGoalRunEnabledForUser(userId, process.env) }; +} + attachPortalCoreAuthRoutes({ app, jsonBody, @@ -614,6 +622,7 @@ attachPortalCoreAuthRoutes({ isSecureRequest, resolveSkillRuntimeForClient, resolveAgentCodeRunForClient, + resolveGoalRunForClient, getPlazaSeo: () => plazaSeo, plazaClientIp, logger: console, @@ -824,6 +833,7 @@ async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } attachPortalUserMemoryRoutes(api, { getMemoryV2: () => memoryV2, getTkmindProxy: () => tkmindProxy, + getPool: () => pool, ensureUserMemoryCapability, ownsAgentSession, loadUserVisibleConversation, @@ -831,6 +841,12 @@ attachPortalUserMemoryRoutes(api, { resolveUserMemoryItems, }); +attachPortalGoalRunRoutes(api, { + getGoalRunService: () => goalRunService, + getAgentRunGateway: () => agentRunGateway, + env: process.env, +}); + attachPortalMindSpaceSpaceRoutes(api, { getMindSpace: () => mindSpace, getScheduleService: () => scheduleService, @@ -1359,6 +1375,8 @@ attachPortalAgentRuntimeRoutes(api, { getTkmindProxy: () => tkmindProxy, getLlmProviderService: () => llmProviderService, getAgentRunGateway: () => agentRunGateway, + getGoalRunService: () => goalRunService, + getChatIntentRouter: () => chatIntentRouter, getSessionAccess: () => sessionAccess, getMindSpaceAssetAgent: () => mindSpaceAssetAgent, getCodeRunPolicyService: () => agentCodeRunPolicyService, diff --git a/server/portal-core-auth-routes.mjs b/server/portal-core-auth-routes.mjs index 38bc03c..a28e786 100644 --- a/server/portal-core-auth-routes.mjs +++ b/server/portal-core-auth-routes.mjs @@ -1,5 +1,6 @@ import { sessionCookie } from '../auth.mjs'; import { isDatabaseConfigured } from '../db.mjs'; +import { isGoalRunEnabledForUser } from '../goal-run-intent.mjs'; import { exchangeMiniProgramCode, loadWechatMiniappConfig, @@ -17,6 +18,9 @@ export function attachPortalCoreAuthRoutes({ isSecureRequest, resolveSkillRuntimeForClient = async () => null, resolveAgentCodeRunForClient = async () => null, + resolveGoalRunForClient = async (userId) => ({ + enabled: isGoalRunEnabledForUser(userId, process.env), + }), getPlazaSeo = () => null, plazaClientIp = (req) => req.ip, logger = console, @@ -59,6 +63,7 @@ export function attachPortalCoreAuthRoutes({ await resolveSkillRuntimeForClient(); const agentCodeRun = await resolveAgentCodeRunForClient(me.id); + const goalRun = await resolveGoalRunForClient(me.id); return res.json({ authenticated: true, user: me, @@ -69,6 +74,7 @@ export function attachPortalCoreAuthRoutes({ unrestricted: capabilityState.unrestricted, skillRuntime, agentCodeRun, + goalRun, }); } catch (error) { logger.error( diff --git a/server/portal-core-auth-routes.test.mjs b/server/portal-core-auth-routes.test.mjs index 2331836..eebbdad 100644 --- a/server/portal-core-auth-routes.test.mjs +++ b/server/portal-core-auth-routes.test.mjs @@ -107,6 +107,10 @@ function createSetup(overrides = {}) { calls.push(['agent-code-run', userId]); return { enabled: true, userId }; }, + async resolveGoalRunForClient(userId) { + calls.push(['goal-run', userId]); + return { enabled: true }; + }, getPlazaSeo: () => plazaSeo, plazaClientIp: () => '203.0.113.5', logger: { @@ -212,6 +216,7 @@ test('returns multi-user status and preserves capability projection', async () = unrestricted: false, skillRuntime: { enabled: true }, agentCodeRun: { enabled: true, userId: 'user-1' }, + goalRun: { enabled: true }, }); }); diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index 4381fc6..2f2ac1f 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { createAgentRunGateway } from '../agent-run-gateway.mjs'; +import { createGoalRunService } from '../goal-run-service.mjs'; import { isDirectChatSessionId } from '../direct-chat-service.mjs'; import { cancelSessionActiveRequest, @@ -236,6 +237,7 @@ export function bootstrapPortalGatewayServices({ 'dev', ).trim() || 'dev', }; + const goalRunService = createGoalRunService({ pool }); const agentRunGateway = createAgentRunGatewayFn({ pool, userAuth, @@ -247,6 +249,7 @@ export function bootstrapPortalGatewayServices({ chatIntentRouter, sessionSnapshotService, conversationMemoryService, + goalRunService, observeWorkflowRun: workflowShadowObserver, observeWorkflowValidation: workflowShadowObserver?.observeValidation ?? null, @@ -363,6 +366,7 @@ export function bootstrapPortalGatewayServices({ tkmindProxy, toolGateway, agentRunGateway, + goalRunService, agentRunRecoveryTimer, validateRunDeliverables, }; diff --git a/server/portal-goal-run-routes.mjs b/server/portal-goal-run-routes.mjs new file mode 100644 index 0000000..3fd11fd --- /dev/null +++ b/server/portal-goal-run-routes.mjs @@ -0,0 +1,239 @@ +import { isGoalRunEnabledForUser, buildGoalContinueUserMessage } from '../goal-run-intent.mjs'; +import { resolveGoalBindingForAgentRun } from '../goal-run-resolve.mjs'; +import crypto from 'node:crypto'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalGoalRunRoutes requires an Express-compatible router', + ); + } +} + +function goalRunUnavailable(res) { + return res.status(503).json({ message: 'Goal Run 功能未启用' }); +} + +function goalRunForbidden(res) { + return res.status(403).json({ message: '当前用户未开启 Goal Run' }); +} + +function parseStatuses(raw) { + const value = String(raw ?? '').trim(); + if (!value) return ['active', 'awaiting_user', 'paused']; + return value.split(',').map((item) => item.trim()).filter(Boolean); +} + +export function attachPortalGoalRunRoutes( + api, + { + getGoalRunService = () => null, + getAgentRunGateway = () => null, + env = process.env, + } = {}, +) { + assertRouter(api); + + function ensureGoalRunAccess(req, res) { + const service = getGoalRunService(); + if (!service) { + goalRunUnavailable(res); + return null; + } + if (!isGoalRunEnabledForUser(req.currentUser?.id, env)) { + goalRunForbidden(res); + return null; + } + return service; + } + + api.post('/goals', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const title = String(req.body?.title ?? '').trim(); + const intentSummary = String( + req.body?.intentSummary ?? req.body?.intent_summary ?? '', + ).trim(); + if (!title || !intentSummary) { + return res.status(400).json({ message: '缺少 title 或 intentSummary' }); + } + + const checkpoints = Array.isArray(req.body?.checkpoints) ? req.body.checkpoints : []; + const goal = await service.createGoalRun({ + userId: req.currentUser.id, + title, + intentSummary, + sourceChannel: String(req.body?.sourceChannel ?? req.body?.source_channel ?? 'api'), + sourceSessionId: req.body?.sourceSessionId ?? req.body?.source_session_id ?? null, + sourceMessageId: req.body?.sourceMessageId ?? req.body?.source_message_id ?? null, + checkpoints, + context: req.body?.context ?? null, + memorySnapshot: req.body?.memorySnapshot ?? req.body?.memory_snapshot ?? null, + }); + return res.status(201).json({ goal }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '创建目标失败', + }); + } + }); + + api.get('/goals', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const goals = await service.listGoalRuns({ + userId: req.currentUser.id, + statuses: parseStatuses(req.query?.status ?? req.query?.statuses), + limit: Number(req.query?.limit ?? 20), + }); + return res.json({ goals }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取目标列表失败', + }); + } + }); + + api.get('/goals/:goalRunId', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const goal = await service.getGoalRun({ + userId: req.currentUser.id, + goalRunId: req.params.goalRunId, + }); + if (!goal) { + return res.status(404).json({ message: '目标不存在' }); + } + return res.json({ goal }); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : '读取目标详情失败', + }); + } + }); + + api.post('/goals/:goalRunId/checkpoints/:checkpointId/approve', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const goal = await service.approveCheckpoint({ + userId: req.currentUser.id, + goalRunId: req.params.goalRunId, + checkpointId: req.params.checkpointId, + feedback: req.body?.feedback ?? null, + }); + + const sessionId = String( + req.body?.sessionId ?? req.body?.session_id ?? '', + ).trim() || null; + let run = null; + const gateway = getAgentRunGateway?.(); + if (sessionId && gateway?.createRun) { + const userMessage = buildGoalContinueUserMessage(req.body?.feedback ?? null); + const requestId = String( + req.body?.request_id ?? req.body?.requestId ?? crypto.randomUUID(), + ).trim(); + const binding = await resolveGoalBindingForAgentRun({ + goalRunService: service, + userId: req.currentUser.id, + userMessage, + sessionId, + body: { goal_run_id: req.params.goalRunId }, + env, + }); + if (binding?.goalRunId) { + run = await gateway.createRun(req.currentUser.id, { + sessionId, + requestId, + userMessage, + goalRunId: binding.goalRunId, + goalCheckpointId: binding.goalCheckpointId, + }); + } + } + + return res.json({ goal, ...(run ? { run } : {}) }); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_FOUND' || err?.code === 'GOAL_CHECKPOINT_NOT_FOUND' + ? 404 + : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '确认阶段失败', + code: err?.code ?? null, + }); + } + }); + + api.post('/goals/:goalRunId/pause', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const goal = await service.pauseGoal({ + userId: req.currentUser.id, + goalRunId: req.params.goalRunId, + }); + return res.json({ goal }); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_PAUSABLE' ? 409 : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '暂停目标失败', + code: err?.code ?? null, + }); + } + }); + + api.post('/goals/:goalRunId/resume', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const result = await service.resumeGoal({ + userId: req.currentUser.id, + goalRunId: req.params.goalRunId, + }); + return res.json(result); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_FOUND' || err?.code === 'GOAL_CHECKPOINT_UNAVAILABLE' + ? 404 + : err?.code === 'GOAL_RUN_NOT_RESUMABLE' + ? 409 + : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '续作目标失败', + code: err?.code ?? null, + }); + } + }); + + api.delete('/goals/:goalRunId', async (req, res) => { + try { + const service = ensureGoalRunAccess(req, res); + if (!service) return; + + const goal = await service.cancelGoal({ + userId: req.currentUser.id, + goalRunId: req.params.goalRunId, + }); + return res.json({ goal }); + } catch (err) { + const status = err?.code === 'GOAL_RUN_NOT_CANCELLABLE' ? 409 : 500; + return res.status(status).json({ + message: err instanceof Error ? err.message : '取消目标失败', + code: err?.code ?? null, + }); + } + }); +} diff --git a/server/portal-goal-run-routes.test.mjs b/server/portal-goal-run-routes.test.mjs new file mode 100644 index 0000000..79ca515 --- /dev/null +++ b/server/portal-goal-run-routes.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalGoalRunRoutes } from './portal-goal-run-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + delete(path, handler) { + routes.set(`DELETE ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + query: {}, + params: {}, + currentUser: { id: 'user-canary' }, + ...overrides, + }; +} + +function enabledGoalRunService(overrides = {}) { + return { + async createGoalRun(input) { + return { + id: 'goal-1', + title: input.title, + intentSummary: input.intentSummary, + checkpoints: [{ id: 'cp-1', title: '启动', status: 'pending' }], + currentCheckpointId: 'cp-1', + }; + }, + async listGoalRuns() { + return [{ id: 'goal-1', title: '长期任务', status: 'active' }]; + }, + async getGoalRun({ goalRunId }) { + if (goalRunId !== 'goal-1') return null; + return { + id: 'goal-1', + title: '长期任务', + checkpoints: [{ id: 'cp-1', title: '启动', status: 'running' }], + }; + }, + async approveCheckpoint() { + return { id: 'goal-1', checkpoints: [{ id: 'cp-1', status: 'approved' }] }; + }, + async pauseGoal() { + return { id: 'goal-1', status: 'paused' }; + }, + async resumeGoal() { + return { goal: { id: 'goal-1', status: 'active' }, checkpointId: 'cp-2' }; + }, + async cancelGoal() { + return { id: 'goal-1', status: 'cancelled' }; + }, + ...overrides, + }; +} + +const enabledEnv = { + GOAL_RUN_ENABLED: '1', + GOAL_RUN_CANARY_USER_IDS: 'user-canary', +}; + +test('Goal Run routes preserve MVP inventory', () => { + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => enabledGoalRunService(), + env: enabledEnv, + }); + assert.deepEqual([...api.routes.keys()], [ + 'POST /goals', + 'GET /goals', + 'GET /goals/:goalRunId', + 'POST /goals/:goalRunId/checkpoints/:checkpointId/approve', + 'POST /goals/:goalRunId/pause', + 'POST /goals/:goalRunId/resume', + 'DELETE /goals/:goalRunId', + ]); +}); + +test('POST /goals returns 503 when service unavailable', async () => { + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => null, + env: enabledEnv, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /goals')( + createRequest({ + body: { title: '任务', intentSummary: '分阶段完成' }, + }), + res, + ); + assert.equal(res.statusCode, 503); +}); + +test('POST /goals creates goal for enabled canary user', async () => { + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => enabledGoalRunService(), + env: enabledEnv, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /goals')( + createRequest({ + body: { + title: '准备下季度产品规划', + intentSummary: '收集竞品并输出草案', + }, + }), + res, + ); + assert.equal(res.statusCode, 201); + assert.equal(res.body.goal.id, 'goal-1'); +}); + +test('GET /goals rejects non-canary user', async () => { + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => enabledGoalRunService(), + env: enabledEnv, + }); + const res = createResponseRecorder(); + await api.routes.get('GET /goals')( + createRequest({ currentUser: { id: 'other-user' } }), + res, + ); + assert.equal(res.statusCode, 403); +}); + +test('POST /goals/:id/resume returns checkpoint binding hint', async () => { + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => enabledGoalRunService(), + env: enabledEnv, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /goals/:goalRunId/resume')( + createRequest({ params: { goalRunId: 'goal-1' } }), + res, + ); + assert.equal(res.statusCode, 200); + assert.equal(res.body.checkpointId, 'cp-2'); +}); + +test('POST approve auto-dispatches agent run when session_id provided', async () => { + const createdRuns = []; + const api = createRouterRecorder(); + attachPortalGoalRunRoutes(api, { + getGoalRunService: () => enabledGoalRunService({ + async getGoalRun({ goalRunId }) { + if (goalRunId !== 'goal-1') return null; + return { + id: 'goal-1', + title: '长期任务', + checkpoints: [ + { id: 'cp-1', title: '启动', status: 'approved' }, + { id: 'cp-2', title: '输出', status: 'pending' }, + ], + }; + }, + async startNextCheckpoint({ goalRunId }) { + return { goalRunId, checkpointId: 'cp-2' }; + }, + async approveCheckpoint() { + return { + id: 'goal-1', + status: 'active', + checkpoints: [{ id: 'cp-1', status: 'approved' }], + }; + }, + }), + getAgentRunGateway: () => ({ + async createRun(userId, payload) { + createdRuns.push({ userId, payload }); + return { + id: 'run-1', + userId, + sessionId: payload.sessionId, + requestId: payload.requestId, + status: 'queued', + }; + }, + }), + env: enabledEnv, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /goals/:goalRunId/checkpoints/:checkpointId/approve')( + createRequest({ + params: { goalRunId: 'goal-1', checkpointId: 'cp-1' }, + body: { session_id: 'session-1', feedback: '继续' }, + }), + res, + ); + assert.equal(res.statusCode, 200); + assert.equal(res.body.goal.id, 'goal-1'); + assert.equal(res.body.run.id, 'run-1'); + assert.equal(createdRuns.length, 1); + assert.equal(createdRuns[0].payload.sessionId, 'session-1'); + assert.equal(createdRuns[0].payload.goalRunId, 'goal-1'); +}); diff --git a/src/App.tsx b/src/App.tsx index e997097..f222f7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} > @@ -123,6 +126,7 @@ export function App() { const [user, setUser] = useState(null); const [capabilities, setCapabilities] = useState(); const [grantedSkills, setGrantedSkills] = useState(); + const [goalRunEnabled, setGoalRunEnabled] = useState(undefined); const [legacyMode, setLegacyMode] = useState(false); const [authUnavailable, setAuthUnavailable] = useState(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(); diff --git a/src/api/client.ts b/src/api/client.ts index 43957f4..109683d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 }, diff --git a/src/api/goalRun.ts b/src/api/goalRun.ts new file mode 100644 index 0000000..103757c --- /dev/null +++ b/src/api/goalRun.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 270e63c..8e0c29a 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -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(null); const [armedSessionId, setArmedSessionId] = useState(null); @@ -487,6 +499,16 @@ export function ChatView({ {user && } + {goalRunBanner.enabled && ( + + )} + {typeof balanceCents === 'number' && balanceCents <= 0 && (
余额不足,请充值后继续使用 diff --git a/src/components/GoalRunAwaitingBanner.tsx b/src/components/GoalRunAwaitingBanner.tsx new file mode 100644 index 0000000..61cf274 --- /dev/null +++ b/src/components/GoalRunAwaitingBanner.tsx @@ -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; + onDismiss: (goalRunId: string) => void; + onRefresh: () => Promise; +}; + +function GoalRunAwaitingCard({ + item, + busy, + onContinue, + onDismiss, + onRefresh, +}: { + item: GoalRunAwaitingItem; + busy: boolean; + onContinue: GoalRunAwaitingBannerProps['onContinue']; + onDismiss: (goalRunId: string) => void; + onRefresh: () => Promise; +}) { + const [feedback, setFeedback] = useState(''); + const [action, setAction] = useState<'continue' | 'cancel' | null>(null); + const [localError, setLocalError] = useState(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 ( +
+
+ {goal.title} + + 阶段「{checkpoint.title}」已完成,请确认后继续下一阶段。 + + {summary ? {summary} : null} +