Files
memind/goal-run-service.mjs
T
john 666db0b939 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>
2026-08-01 17:03:16 +08:00

662 lines
23 KiB
JavaScript

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 });
},
};
}