merge: integrate langgraph execution runtime
# Conflicts: # agent-run-gateway.test.mjs # capabilities.mjs # package.json # server.mjs
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const [action = '', ...rest] = argv;
|
||||
const options = { action };
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const item = rest[index];
|
||||
if (item === '--output') options.output = rest[++index];
|
||||
else if (item === '--input') options.input = rest[++index];
|
||||
else if (item === '--database-url') options.databaseUrl = rest[++index];
|
||||
else if (item === '--confirm-empty-target') options.confirmEmptyTarget = true;
|
||||
else if (item === '--allow-remote-target') options.allowRemoteTarget = true;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, { env = process.env } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
shell: false,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with ${signal ?? code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function databaseUrl(options) {
|
||||
const value = String(
|
||||
options.databaseUrl ?? process.env.MEMIND_ORCHESTRATOR_DATABASE_URL ?? '',
|
||||
).trim();
|
||||
if (!value) throw new Error('Provide --database-url or MEMIND_ORCHESTRATOR_DATABASE_URL');
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireLocalOrExplicitRemote(url, options) {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
const local = ['localhost', '127.0.0.1', '::1', 'postgres'].includes(hostname);
|
||||
if (!local && !options.allowRemoteTarget) {
|
||||
throw new Error('Remote database targets require --allow-remote-target');
|
||||
}
|
||||
}
|
||||
|
||||
async function backup(options) {
|
||||
const output = path.resolve(String(options.output ?? ''));
|
||||
if (!options.output || path.extname(output) !== '.dump') {
|
||||
throw new Error('Backup requires an explicit --output path ending in .dump');
|
||||
}
|
||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||
await fs.access(output).then(
|
||||
() => Promise.reject(new Error(`Refusing to overwrite existing backup: ${output}`)),
|
||||
() => null,
|
||||
);
|
||||
await run('pg_dump', [
|
||||
'--format=custom',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--file',
|
||||
output,
|
||||
databaseUrl(options),
|
||||
]);
|
||||
console.log(JSON.stringify({ ok: true, action: 'backup', output }));
|
||||
}
|
||||
|
||||
async function verify(options) {
|
||||
const input = path.resolve(String(options.input ?? ''));
|
||||
if (!options.input) throw new Error('Verify requires --input');
|
||||
await fs.access(input);
|
||||
await run('pg_restore', ['--list', input]);
|
||||
console.log(JSON.stringify({ ok: true, action: 'verify', input }));
|
||||
}
|
||||
|
||||
async function restore(options) {
|
||||
if (!options.confirmEmptyTarget) {
|
||||
throw new Error('Restore requires --confirm-empty-target and never cleans an existing database');
|
||||
}
|
||||
const input = path.resolve(String(options.input ?? ''));
|
||||
if (!options.input) throw new Error('Restore requires --input');
|
||||
await fs.access(input);
|
||||
const target = databaseUrl(options);
|
||||
requireLocalOrExplicitRemote(target, options);
|
||||
await run('pg_restore', [
|
||||
'--exit-on-error',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--dbname',
|
||||
target,
|
||||
input,
|
||||
]);
|
||||
console.log(JSON.stringify({ ok: true, action: 'restore', input }));
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
try {
|
||||
if (options.action === 'backup') await backup(options);
|
||||
else if (options.action === 'verify') await verify(options);
|
||||
else if (options.action === 'restore') await restore(options);
|
||||
else {
|
||||
throw new Error(
|
||||
'Usage: orchestrator-dr.mjs backup --output file.dump | verify --input file.dump | restore --input file.dump --confirm-empty-target',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export const orchestratorDrInternals = {
|
||||
databaseUrl,
|
||||
parseArgs,
|
||||
requireLocalOrExplicitRemote,
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'node:crypto';
|
||||
import { createAgentRunGateway } from '../agent-run-gateway.mjs';
|
||||
import { createDbPool, isDatabaseConfigured } from '../db.mjs';
|
||||
import { createOrchestratorAdminConfigService } from '../services/orchestrator/admin-config.mjs';
|
||||
import { createOrchestratorObservabilityService } from '../services/orchestrator/observability.mjs';
|
||||
import { createWorkflowShadowObserver } from '../services/orchestrator/shadow-observer.mjs';
|
||||
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
||||
|
||||
loadMemindEnvFiles(new URL('..', import.meta.url).pathname);
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const cleanup = args.has('--cleanup');
|
||||
const restoreConfig = args.has('--restore-config');
|
||||
const serviceUrl = String(
|
||||
process.env.MEMIND_ORCHESTRATOR_URL ?? 'http://127.0.0.1:8093',
|
||||
).trim().replace(/\/$/, '');
|
||||
const serviceToken = String(process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN ?? '').trim();
|
||||
|
||||
function assertLocalServiceUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(url.hostname) && !args.has('--allow-remote')) {
|
||||
throw new Error('Shadow smoke defaults to a loopback Orchestrator; pass --allow-remote explicitly');
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForShadowEvent(pool, runId, timeoutMs = 15_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT event_type, data_json, created_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE run_id = ?
|
||||
AND event_type IN ('workflow_shadow_completed', 'workflow_shadow_failed')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[runId],
|
||||
);
|
||||
if (rows[0]) return rows[0];
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Shadow projection for run ${runId}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
throw new Error('Shadow smoke requires the local Memind MySQL configuration');
|
||||
}
|
||||
if (!serviceToken) {
|
||||
throw new Error('MEMIND_ORCHESTRATOR_SERVICE_TOKEN is required');
|
||||
}
|
||||
assertLocalServiceUrl(serviceUrl);
|
||||
const ready = await fetch(`${serviceUrl}/ready`);
|
||||
if (!ready.ok) throw new Error(`Orchestrator readiness failed with HTTP ${ready.status}`);
|
||||
|
||||
const pool = createDbPool();
|
||||
const configService = createOrchestratorAdminConfigService(pool);
|
||||
const previous = await configService.getAdminConfig();
|
||||
let runId = null;
|
||||
try {
|
||||
await configService.updateAdminConfig({
|
||||
...previous.config,
|
||||
mode: 'shadow',
|
||||
serviceUrl,
|
||||
primaryEngine: 'langgraph',
|
||||
workflowAllowlist: ['code-run-v1'],
|
||||
});
|
||||
const [users] = await pool.query(
|
||||
`SELECT id
|
||||
FROM h5_users
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (!users[0]?.id) throw new Error('Shadow smoke requires at least one local user');
|
||||
|
||||
const observer = createWorkflowShadowObserver({
|
||||
configService,
|
||||
serviceToken,
|
||||
});
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
tkmindProxy: {},
|
||||
autoDispatch: false,
|
||||
observeWorkflowRun: observer,
|
||||
});
|
||||
const requestId = `orchestrator-shadow-smoke-${Date.now()}-${crypto.randomUUID()}`;
|
||||
const run = await gateway.createRun(users[0].id, {
|
||||
requestId,
|
||||
toolMode: 'code',
|
||||
taskType: 'orchestrator_shadow_smoke',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Observe this local smoke run without executing tools.' }],
|
||||
},
|
||||
});
|
||||
runId = run.id;
|
||||
const event = await waitForShadowEvent(pool, runId);
|
||||
if (event.event_type !== 'workflow_shadow_completed') {
|
||||
throw new Error(`Shadow smoke failed: ${JSON.stringify(event.data_json)}`);
|
||||
}
|
||||
|
||||
const completedAt = Date.now();
|
||||
await pool.query(
|
||||
`UPDATE h5_agent_runs
|
||||
SET status = 'succeeded', updated_at = ?, completed_at = ?
|
||||
WHERE id = ? AND status = 'queued'`,
|
||||
[completedAt, completedAt, runId],
|
||||
);
|
||||
|
||||
const observability = createOrchestratorObservabilityService({
|
||||
pool,
|
||||
configService,
|
||||
serviceToken,
|
||||
});
|
||||
const [summary, detail] = await Promise.all([
|
||||
observability.listShadowRuns({ hours: 1, limit: 10 }),
|
||||
observability.getShadowRun(runId),
|
||||
]);
|
||||
if (!detail?.remote?.available || detail.remote.state?.status !== 'succeeded') {
|
||||
throw new Error('Shadow checkpoint detail was not recoverable through the remote API');
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
runId,
|
||||
mode: 'shadow',
|
||||
shadowEvent: event.event_type,
|
||||
metrics: summary.metrics,
|
||||
checkpointStatus: detail.remote.state.status,
|
||||
graphEvents: detail.remote.events.map((item) => item.type),
|
||||
cleanup,
|
||||
restoreConfig,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
if (cleanup && runId) {
|
||||
await pool.query('DELETE FROM h5_agent_runs WHERE id = ?', [runId]);
|
||||
}
|
||||
if (restoreConfig) {
|
||||
await configService.updateAdminConfig(previous.config);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user