Add Experience V1 schema migration and agent-run completion extractor.
Extend h5_experience with structured fields, wire mindspace-agent-runner and agent-run-gateway to persist task_outcome records with provenance, and add local migration and verification scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { createDirectChatService } from '../direct-chat-service.mjs';
|
||||
import { createSessionAccess } from '../session-broker.mjs';
|
||||
import { createOrchestratorAdminConfigService } from '../services/orchestrator/admin-config.mjs';
|
||||
import { createWorkflowShadowObserver } from '../services/orchestrator/shadow-observer.mjs';
|
||||
import { createExperienceService } from '../experience-service.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -196,6 +197,7 @@ async function bootstrapWorker() {
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
userAuth,
|
||||
});
|
||||
const experienceService = createExperienceService(pool);
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth,
|
||||
@@ -206,6 +208,7 @@ async function bootstrapWorker() {
|
||||
sessionSnapshotService,
|
||||
conversationMemoryService,
|
||||
chatIntentRouter,
|
||||
experienceService,
|
||||
conversationMemoryService,
|
||||
autoDispatch: false,
|
||||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Idempotent migration: add h5_experience V1 columns (problem, environment, evidence, …).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/migrate-h5-experience-v1.mjs
|
||||
* node scripts/migrate-h5-experience-v1.mjs --dry-run
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool, migrateSchema } from '../db.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { dryRun: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '-h' || arg === '--help') {
|
||||
console.log('Usage: node scripts/migrate-h5-experience-v1.mjs [--dry-run]');
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadH5Environment(import.meta.dirname);
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('[migrate-h5-experience-v1] dry-run: would run migrateSchema() (includes h5_experience V1 ALTERs)');
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
try {
|
||||
await migrateSchema(pool);
|
||||
console.log('[migrate-h5-experience-v1] ok');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[migrate-h5-experience-v1] failed:', error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local E2E: agent run terminal → h5_experience via Experience Extractor.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-experience-agent-run-local.mjs
|
||||
*
|
||||
* Requires: .env MySQL, goosed or direct-chat LLM path.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createAgentRunGateway } from '../agent-run-gateway.mjs';
|
||||
import { createManagedChatIntentRouter } from '../chat-intent-router.mjs';
|
||||
import { createConversationMemoryService } from '../conversation-memory.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createEpisodicMemoryService } from '../episodic-memory.mjs';
|
||||
import { createExperienceService } from '../experience-service.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs';
|
||||
import { createManagedMemoryV2Runtime } from '../memory-v2-runtime.mjs';
|
||||
import { createTkmindProxy } from '../tkmind-proxy.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
import { createSessionSnapshotService } from '../session-snapshot.mjs';
|
||||
import { createDirectChatService } from '../direct-chat-service.mjs';
|
||||
import { createSessionAccess } from '../session-broker.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||
const USERNAME = process.env.MEMIND_E2E_USERNAME ?? 'john';
|
||||
const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888';
|
||||
const VERIFY_MESSAGE = process.env.EXPERIENCE_E2E_MESSAGE
|
||||
?? 'Experience E2E 验证:请用一句话回复收到即可';
|
||||
const MAX_WAIT_MS = Number(process.env.EXPERIENCE_E2E_WAIT_MS ?? 180_000);
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function loginViaApi(pool) {
|
||||
const response = await fetch(`${PORTAL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (response.ok && body?.authenticated && body.user?.id) {
|
||||
return { userId: body.user.id };
|
||||
}
|
||||
|
||||
const auth = createUserAuth(pool, {
|
||||
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
|
||||
h5Root: root,
|
||||
});
|
||||
const result = await auth.login({ username: USERNAME, password: PASSWORD, ip: '127.0.0.1' });
|
||||
if (!result.ok || !result.user?.id) {
|
||||
throw new Error(`登录失败: ${result.message ?? body?.message ?? 'unknown'}`);
|
||||
}
|
||||
return { userId: result.user.id };
|
||||
}
|
||||
|
||||
async function resolveUserId(pool) {
|
||||
const forcedUserId = String(process.env.EXPERIENCE_E2E_USER_ID ?? '').trim();
|
||||
if (forcedUserId) return forcedUserId;
|
||||
|
||||
try {
|
||||
const { userId } = await loginViaApi(pool);
|
||||
if (userId) return userId;
|
||||
} catch (error) {
|
||||
console.warn('[experience-e2e] API/DB login skipped:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id FROM h5_users WHERE username = ? LIMIT 1',
|
||||
[USERNAME],
|
||||
);
|
||||
if (rows[0]?.id) {
|
||||
console.log(`[experience-e2e] using user id from DB lookup: ${USERNAME}`);
|
||||
return rows[0].id;
|
||||
}
|
||||
throw new Error(`找不到用户 ${USERNAME},可设置 EXPERIENCE_E2E_USER_ID`);
|
||||
}
|
||||
|
||||
async function waitForTerminal(pool, runId, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT status, error_message FROM h5_agent_runs WHERE id = ? LIMIT 1',
|
||||
[runId],
|
||||
);
|
||||
const status = rows[0]?.status;
|
||||
if (status === 'succeeded' || status === 'failed') {
|
||||
return rows[0];
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`run ${runId} 未在 ${timeoutMs}ms 内进入终态`);
|
||||
}
|
||||
|
||||
async function findExperienceForRun(pool, runId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, kind, title, problem, result, status,
|
||||
JSON_EXTRACT(evidence_json, '$.provenance.run_id') AS run_id,
|
||||
created_at
|
||||
FROM h5_experience
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(evidence_json, '$.provenance.run_id')) = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[runId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function bootstrapGateway(pool) {
|
||||
const userAuth = createUserAuth(pool, {
|
||||
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
|
||||
h5Root: root,
|
||||
});
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
});
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||
const getEffectiveEnv = async () => {
|
||||
const state = await memoryV2ConfigService.getRuntimeState().catch(() => null);
|
||||
return { ...process.env, ...(state?.overrides ?? {}) };
|
||||
};
|
||||
const conversationMemoryService = createConversationMemoryService(pool, {
|
||||
llmProviderService,
|
||||
getEffectiveEnv,
|
||||
});
|
||||
const memoryV2 = await createManagedMemoryV2Runtime({
|
||||
legacyMemoryService: conversationMemoryService,
|
||||
configService: memoryV2ConfigService,
|
||||
mysqlPool: pool,
|
||||
});
|
||||
const episodicMemoryService = createEpisodicMemoryService(pool, {
|
||||
getEffectiveEnv,
|
||||
logger: console,
|
||||
});
|
||||
const sessionSnapshotService = createSessionSnapshotService(pool, {
|
||||
conversationMemoryService,
|
||||
memoryV2,
|
||||
episodicMemoryService,
|
||||
});
|
||||
const sessionAccess = createSessionAccess({ userAuth, enabled: false });
|
||||
const directChatService = createDirectChatService({
|
||||
userAuth,
|
||||
sessionAccess,
|
||||
llmProviderService,
|
||||
sessionSnapshotService,
|
||||
memoryV2,
|
||||
conversationMemoryService,
|
||||
episodicMemoryService,
|
||||
});
|
||||
const chatIntentRouter = createManagedChatIntentRouter({
|
||||
llmProviderService,
|
||||
memoryV2,
|
||||
conversationMemoryService,
|
||||
episodicMemoryService,
|
||||
configService: memoryV2ConfigService,
|
||||
});
|
||||
const tkmindProxy = createTkmindProxy({
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiTargets: String(process.env.TKMIND_API_TARGETS ?? '').split(',').filter(Boolean).length
|
||||
? String(process.env.TKMIND_API_TARGETS).split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: [process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'],
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
userAuth,
|
||||
});
|
||||
const experienceService = createExperienceService(pool);
|
||||
return createAgentRunGateway({
|
||||
pool,
|
||||
userAuth,
|
||||
sessionAccess,
|
||||
tkmindProxy,
|
||||
toolGateway: createToolGateway({ llmProviderService }),
|
||||
directChatService,
|
||||
sessionSnapshotService,
|
||||
conversationMemoryService,
|
||||
chatIntentRouter,
|
||||
experienceService,
|
||||
autoDispatch: false,
|
||||
retryDelaysMs: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = createDbPool();
|
||||
try {
|
||||
console.log('[experience-e2e] login...');
|
||||
const userId = await resolveUserId(pool);
|
||||
|
||||
const gateway = await bootstrapGateway(pool);
|
||||
|
||||
console.log('[experience-e2e] create + dispatch run (Experience Extractor gateway)...');
|
||||
const run = await gateway.createRun(userId, {
|
||||
requestId: crypto.randomUUID(),
|
||||
userMessage: {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: VERIFY_MESSAGE }],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
displayText: VERIFY_MESSAGE,
|
||||
},
|
||||
},
|
||||
});
|
||||
const runId = run.id;
|
||||
console.log('[experience-e2e] run created:', runId, 'status:', run.status);
|
||||
|
||||
gateway.dispatchRun(runId);
|
||||
await sleep(500);
|
||||
while ((await gateway.getQueueStatus()).inFlight > 0) {
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const terminal = await waitForTerminal(pool, runId, MAX_WAIT_MS);
|
||||
console.log('[experience-e2e] run terminal:', terminal.status, terminal.error_message ?? '');
|
||||
|
||||
const experience = await findExperienceForRun(pool, runId);
|
||||
if (!experience) {
|
||||
console.error('[experience-e2e] FAIL: no h5_experience row for run', runId);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[experience-e2e] PASS');
|
||||
console.log(JSON.stringify({
|
||||
runId,
|
||||
runStatus: terminal.status,
|
||||
experience: {
|
||||
id: experience.id,
|
||||
kind: experience.kind,
|
||||
problem: experience.problem,
|
||||
result: experience.result,
|
||||
status: experience.status,
|
||||
run_id: experience.run_id,
|
||||
},
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[experience-e2e] failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user