feat(goal-run): add multi-checkpoint goal orchestration with H5 and admin surfaces.
Persist goal runs in MySQL, bind agent runs to checkpoints, expose awaiting-approval UX in chat, and add admin inspection routes with local verify scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user