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:
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user