mindspace: version runtime contracts and audit packages

This commit is contained in:
john
2026-07-27 17:43:38 +08:00
parent e94052ff24
commit 70264a4c4c
23 changed files with 1448 additions and 20 deletions
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
import mysql from 'mysql2/promise';
import {
auditConversationPackages,
} from '../mindspace-conversation-package-audit.mjs';
import {
loadMemindEnvFiles,
} from './memind-runtime-profile.mjs';
function usage() {
return [
'Usage: node scripts/audit-conversation-packages.mjs [--user-id <id>] [--session-id <id>] [--limit <n>] [--repair] [--json]',
'',
'Audits recent conversation packages for missing/invalid artifact references.',
'Default mode is read-only. --repair only inserts missing public_html artifacts',
'when a generated_file artifact already points at a public/*.html h5_asset.',
].join('\n');
}
function parseArgs(argv) {
const args = {
userId: '',
sessionId: '',
limit: 100,
repair: false,
json: false,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--user-id' && argv[i + 1]) {
args.userId = argv[++i];
} else if (arg === '--session-id' && argv[i + 1]) {
args.sessionId = argv[++i];
} else if (arg === '--limit' && argv[i + 1]) {
args.limit = Math.max(1, Math.min(1000, Number(argv[++i]) || 100));
} else if (arg === '--repair') {
args.repair = true;
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
return args;
}
function createPoolFromEnv() {
loadMemindEnvFiles(process.cwd());
const poolOptions = { connectionLimit: 3 };
if (process.env.DATABASE_URL) {
return mysql.createPool({
uri: process.env.DATABASE_URL,
...poolOptions,
});
}
if (!process.env.MYSQL_HOST && !process.env.MYSQL_DATABASE) {
throw new Error(
'conversation package audit requires DATABASE_URL or MYSQL_* configuration',
);
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? 'localhost',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
...poolOptions,
});
}
function whereClause(args) {
const clauses = ['p.status <> ?'];
const values = ['deleted'];
if (args.userId) {
clauses.push('p.user_id = ?');
values.push(args.userId);
}
if (args.sessionId) {
clauses.push('p.session_id = ?');
values.push(args.sessionId);
}
return {
sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
values,
};
}
async function loadAuditRows(pool, args) {
const where = whereClause(args);
const [packages] = await pool.query(
`SELECT p.id, p.user_id, p.session_id, p.title, p.status,
p.storage_prefix, p.manifest_asset_id, p.created_at, p.updated_at
FROM h5_conversation_packages p
${where.sql}
ORDER BY p.updated_at DESC
LIMIT ?`,
[...where.values, args.limit],
);
if (packages.length === 0) {
return { packages: [], artifacts: [], assets: [] };
}
const packageIds = packages.map((item) => item.id);
const placeholders = packageIds.map(() => '?').join(',');
const [artifacts] = await pool.query(
`SELECT ca.id, ca.package_id, ca.asset_id, ca.page_id,
ca.publication_id, ca.agent_run_id, ca.message_id, ca.role,
ca.artifact_kind, ca.display_name, ca.mime_type,
ca.size_bytes, ca.storage_key, ca.canonical_url,
ca.sort_order, ca.created_at
FROM h5_conversation_artifacts ca
WHERE ca.package_id IN (${placeholders})
ORDER BY ca.package_id ASC, ca.sort_order ASC, ca.created_at ASC`,
packageIds,
);
const assetIds = [
...new Set(
artifacts
.map((item) => item.asset_id)
.filter(Boolean),
),
];
let assets = [];
if (assetIds.length > 0) {
const assetPlaceholders = assetIds.map(() => '?').join(',');
[assets] = await pool.query(
`SELECT a.id, a.user_id, a.display_name, a.workspace_relative_path,
a.asset_type, a.mime_type, a.size_bytes, a.status,
a.visibility, a.updated_at
FROM h5_assets a
WHERE a.id IN (${assetPlaceholders})`,
assetIds,
);
}
return { packages, artifacts, assets };
}
async function applyRepairs(pool, repairs) {
const applied = [];
for (const repair of repairs) {
await pool.query(
`INSERT INTO h5_conversation_artifacts
(id, package_id, artifact_kind, role, asset_id, page_id, publication_id,
agent_run_id, message_id, display_name, mime_type, size_bytes,
storage_key, canonical_url, sort_order, created_at)
VALUES (?, ?, 'public_html', 'assistant', NULL, NULL, NULL,
NULL, NULL, ?, ?, ?, NULL, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
mime_type = VALUES(mime_type),
size_bytes = VALUES(size_bytes),
canonical_url = VALUES(canonical_url),
sort_order = VALUES(sort_order)`,
[
repair.artifactId,
repair.packageId,
repair.displayName,
repair.mimeType,
repair.sizeBytes,
repair.canonicalUrl,
Math.round(Number(repair.sortOrder) || Date.now()),
Date.now(),
],
);
applied.push(repair);
}
return applied;
}
function printTextReport(result, { applied = [] } = {}) {
console.log(
`conversation package audit: ${result.ok ? 'ok' : 'issues found'}`,
);
console.log(JSON.stringify(result.summary, null, 2));
if (result.issues.length > 0) {
for (const item of result.issues) {
console.log(
`- ${item.code}: ${item.message}${
item.packageId ? ` [package=${item.packageId}]` : ''
}`,
);
}
}
if (applied.length > 0) {
console.log(`applied repairs: ${applied.length}`);
for (const item of applied) {
console.log(
`- inserted ${item.artifactId} for ${item.packageId} ${item.relativePath}`,
);
}
}
}
const args = parseArgs(process.argv);
const pool = createPoolFromEnv();
try {
const rows = await loadAuditRows(pool, args);
const result = auditConversationPackages({
...rows,
publicBaseUrl:
process.env.H5_PUBLIC_BASE_URL ?? 'http://127.0.0.1:5173',
});
const applied = args.repair
? await applyRepairs(pool, result.repairs)
: [];
if (args.json) {
console.log(JSON.stringify({ ...result, applied }, null, 2));
} else {
printTextReport(result, { applied });
}
process.exit(result.ok || args.repair ? 0 : 1);
} finally {
await pool.end();
}
@@ -2,6 +2,8 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
@@ -9,6 +11,7 @@ const runtimeRoot = path.join(root, '.runtime', 'mindspace-service');
const serviceSourceRoot = path.join(root, 'mindspace-service');
const nodeModulesDir = path.join(root, 'node_modules');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const execFileAsync = promisify(execFile);
const excludeTopLevel = new Set([
'.git',
@@ -167,6 +170,14 @@ async function copyNodeModules() {
async function writeMetadata() {
const head = await fs.readFile(path.join(root, '.git', 'HEAD'), 'utf8').catch(() => 'unknown');
const gitSha = await execFileAsync('git', ['-C', root, 'rev-parse', 'HEAD'])
.then(({ stdout }) => stdout.trim())
.catch(() => null);
const gitBranch = await execFileAsync('git', ['-C', root, 'branch', '--show-current'])
.then(({ stdout }) => stdout.trim())
.catch(() => null);
const builtAt = new Date().toISOString();
const buildId = gitSha ? `mindspace-${gitSha.slice(0, 12)}` : `mindspace-${Date.now()}`;
const runbook = [
'MindSpace service runtime artifact',
'',
@@ -197,8 +208,22 @@ async function writeMetadata() {
'but standalone MindSpace data paths must stay under /Users/john/MindSpace.',
'',
`Git head ref: ${head.trim()}`,
`Git sha: ${gitSha ?? 'unknown'}`,
`Git branch: ${gitBranch ?? 'unknown'}`,
`Build id: ${buildId}`,
`Built at: ${builtAt}`,
].join('\n');
await fs.writeFile(path.join(runtimeRoot, 'RUNBOOK.txt'), runbook, 'utf8');
await fs.writeFile(
path.join(runtimeRoot, 'build-info.json'),
`${JSON.stringify({
buildId,
gitSha,
gitBranch,
builtAt,
}, null, 2)}\n`,
'utf8',
);
}
async function main() {
+70 -1
View File
@@ -118,7 +118,7 @@ fi
verify_runtime_artifact() {
local missing=0
for required in README.md package.json server.mjs mindspace-rpc-server.mjs mindspace-service-bootstrap.mjs scripts/run-mindspace-prod.sh memind-source RUNBOOK.txt; do
for required in README.md package.json server.mjs mindspace-rpc-server.mjs mindspace-service-bootstrap.mjs scripts/run-mindspace-prod.sh memind-source RUNBOOK.txt build-info.json; do
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
missing=1
@@ -364,6 +364,75 @@ done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]]
EXPECTED_GIT_SHA="$(awk -F= '/^git_head=/{print $2}' "${MANIFEST}" | tail -n 1)"
CONTRACT_JSON="$(curl -fsS http://127.0.0.1:8082/mindspace/v1/contract)"
EXPECTED_GIT_SHA="${EXPECTED_GIT_SHA}" CONTRACT_JSON="${CONTRACT_JSON}" \
/opt/homebrew/opt/node@24/bin/node --input-type=module <<'NODE'
const expectedGitSha = process.env.EXPECTED_GIT_SHA || '';
const contract = JSON.parse(process.env.CONTRACT_JSON || '{}');
const requiredCapabilities = [
'chat-save-authority',
'public-finish-authority',
'workspace-publication-delivery-authority',
'scoped-workspace-tools',
];
const requiredBindings = {
chatSaveService: [
'createSharedHtml',
'materializeWorkspaceHtml',
'readWorkspaceHtml',
],
conversationArtifactService: [
'registerPublicHtmlArtifacts',
'registerWorkspaceFileArtifact',
],
publicFinishService: [
'prepareWechatHtmlDelivery',
'syncAfterFinish',
],
workspacePublicationDeliveryService: [
'resolveWorkspaceRequest',
'validateRunDeliverables',
],
workspaceToolService: [
'readFile',
'writeFile',
'editFile',
'publishPage',
'writeBinaryFile',
],
};
const exposedCapabilities = new Set(contract.requiredCapabilities || []);
const missingCapabilities = requiredCapabilities.filter((item) => !exposedCapabilities.has(item));
const missingBindings = [];
for (const [binding, methods] of Object.entries(requiredBindings)) {
if (!Array.isArray(contract.bindings?.[binding])) {
missingBindings.push(binding);
continue;
}
for (const method of methods) {
if (!contract.bindings[binding].includes(method)) {
missingBindings.push(`${binding}.${method}`);
}
}
}
if (
contract.contractVersion !== 2 ||
missingCapabilities.length > 0 ||
missingBindings.length > 0 ||
(expectedGitSha && contract.gitSha !== expectedGitSha)
) {
console.error(JSON.stringify({
message: 'MindSpace service contract check failed',
contractVersion: contract.contractVersion,
gitSha: contract.gitSha,
expectedGitSha,
missingCapabilities,
missingBindings,
}, null, 2));
process.exit(1);
}
NODE
set_env() {
local key="$1"
+1 -1
View File
@@ -212,7 +212,7 @@ async function main() {
authToken: localToken,
timeoutMs: 20_000,
});
remote.assertReady();
await remote.assertReady();
const workspaceRef = `mindspace://users/${userId}/workspace`;
const html = pageHtml();
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env node
import mysql from 'mysql2/promise';
import {
parseMindSpacePublicUrl,
} from '../mindspace-canonical-url.mjs';
import {
loadMemindEnvFiles,
} from './memind-runtime-profile.mjs';
function usage() {
return [
'Usage: node scripts/trace-mindspace-artifact.mjs (--session-id <id> | --package-id <id> | --asset-id <id> | --public-url <url>) [--user-id <id>] [--json]',
'',
'Traces MindSpace package/artifact/asset records for a session, package, asset, or public URL.',
].join('\n');
}
function parseArgs(argv) {
const args = {
sessionId: '',
packageId: '',
assetId: '',
publicUrl: '',
userId: '',
json: false,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--session-id' && argv[i + 1]) args.sessionId = argv[++i];
else if (arg === '--package-id' && argv[i + 1]) args.packageId = argv[++i];
else if (arg === '--asset-id' && argv[i + 1]) args.assetId = argv[++i];
else if (arg === '--public-url' && argv[i + 1]) args.publicUrl = argv[++i];
else if (arg === '--user-id' && argv[i + 1]) args.userId = argv[++i];
else if (arg === '--json') args.json = true;
else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
if (!args.sessionId && !args.packageId && !args.assetId && !args.publicUrl) {
console.error(usage());
process.exit(2);
}
return args;
}
function createPoolFromEnv() {
loadMemindEnvFiles(process.cwd());
const poolOptions = { connectionLimit: 3 };
if (process.env.DATABASE_URL) {
return mysql.createPool({
uri: process.env.DATABASE_URL,
...poolOptions,
});
}
if (!process.env.MYSQL_HOST && !process.env.MYSQL_DATABASE) {
throw new Error(
'MindSpace trace requires DATABASE_URL or MYSQL_* configuration',
);
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? 'localhost',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
...poolOptions,
});
}
async function resolvePublicUrlTarget(pool, publicUrl) {
const parsed = parseMindSpacePublicUrl(publicUrl);
const ownerKey = parsed.ownerKey;
let userId = ownerKey;
const [users] = await pool.query(
`SELECT id, username FROM h5_users
WHERE id = ? OR username = ?
LIMIT 1`,
[ownerKey, ownerKey],
);
if (users[0]?.id) userId = users[0].id;
const [assets] = await pool.query(
`SELECT id FROM h5_assets
WHERE user_id = ? AND workspace_relative_path = ?
ORDER BY updated_at DESC
LIMIT 1`,
[userId, parsed.relativePath],
);
return {
userId,
relativePath: parsed.relativePath,
assetId: assets[0]?.id ?? '',
};
}
async function loadTrace(pool, args) {
let userId = args.userId;
let assetId = args.assetId;
let relativePath = '';
if (args.publicUrl) {
const resolved = await resolvePublicUrlTarget(pool, args.publicUrl);
userId = userId || resolved.userId;
assetId = assetId || resolved.assetId;
relativePath = resolved.relativePath;
}
const packageClauses = [];
const packageValues = [];
if (args.packageId) {
packageClauses.push('p.id = ?');
packageValues.push(args.packageId);
}
if (args.sessionId) {
packageClauses.push('p.session_id = ?');
packageValues.push(args.sessionId);
}
if (userId) {
packageClauses.push('p.user_id = ?');
packageValues.push(userId);
}
if (assetId) {
packageClauses.push(
`p.id IN (
SELECT package_id FROM h5_conversation_artifacts WHERE asset_id = ?
)`,
);
packageValues.push(assetId);
}
const [packages] = packageClauses.length
? await pool.query(
`SELECT p.id, p.user_id, u.username, p.session_id, p.title,
p.status, p.storage_prefix, p.manifest_asset_id,
p.created_at, p.updated_at
FROM h5_conversation_packages p
LEFT JOIN h5_users u ON u.id = p.user_id
WHERE ${packageClauses.join(' AND ')}
ORDER BY p.updated_at DESC
LIMIT 20`,
packageValues,
)
: [[]];
const packageIds = packages.map((item) => item.id);
let artifacts = [];
if (packageIds.length > 0) {
const placeholders = packageIds.map(() => '?').join(',');
[artifacts] = await pool.query(
`SELECT ca.id, ca.package_id, ca.asset_id, ca.page_id,
ca.publication_id, ca.agent_run_id, ca.message_id, ca.role,
ca.artifact_kind, ca.display_name, ca.mime_type,
ca.size_bytes, ca.storage_key, ca.canonical_url,
ca.sort_order, ca.created_at
FROM h5_conversation_artifacts ca
WHERE ca.package_id IN (${placeholders})
ORDER BY ca.package_id ASC, ca.sort_order ASC, ca.created_at ASC`,
packageIds,
);
} else if (assetId) {
[artifacts] = await pool.query(
`SELECT ca.id, ca.package_id, ca.asset_id, ca.page_id,
ca.publication_id, ca.agent_run_id, ca.message_id, ca.role,
ca.artifact_kind, ca.display_name, ca.mime_type,
ca.size_bytes, ca.storage_key, ca.canonical_url,
ca.sort_order, ca.created_at
FROM h5_conversation_artifacts ca
WHERE ca.asset_id = ?
ORDER BY ca.created_at DESC
LIMIT 20`,
[assetId],
);
}
const assetIds = [
...new Set(
[
assetId,
...artifacts.map((item) => item.asset_id),
].filter(Boolean),
),
];
let assets = [];
if (assetIds.length > 0) {
const placeholders = assetIds.map(() => '?').join(',');
[assets] = await pool.query(
`SELECT a.id, a.user_id, u.username, a.display_name,
a.workspace_relative_path, a.asset_type, a.mime_type,
a.size_bytes, a.status, a.visibility, a.updated_at
FROM h5_assets a
LEFT JOIN h5_users u ON u.id = a.user_id
WHERE a.id IN (${placeholders})
ORDER BY a.updated_at DESC`,
assetIds,
);
} else if (userId && relativePath) {
[assets] = await pool.query(
`SELECT a.id, a.user_id, u.username, a.display_name,
a.workspace_relative_path, a.asset_type, a.mime_type,
a.size_bytes, a.status, a.visibility, a.updated_at
FROM h5_assets a
LEFT JOIN h5_users u ON u.id = a.user_id
WHERE a.user_id = ? AND a.workspace_relative_path = ?
ORDER BY a.updated_at DESC`,
[userId, relativePath],
);
}
return {
target: {
sessionId: args.sessionId || null,
packageId: args.packageId || null,
assetId: assetId || null,
publicUrl: args.publicUrl || null,
userId: userId || null,
relativePath: relativePath || null,
},
packages,
artifacts,
assets,
};
}
function printTrace(trace) {
console.log('MindSpace trace target:');
console.log(JSON.stringify(trace.target, null, 2));
console.log(`packages: ${trace.packages.length}`);
for (const item of trace.packages) {
console.log(
`- ${item.id} session=${item.session_id} user=${item.username ?? item.user_id} updated=${item.updated_at}`,
);
}
console.log(`artifacts: ${trace.artifacts.length}`);
for (const item of trace.artifacts) {
console.log(
`- ${item.id} kind=${item.artifact_kind} package=${item.package_id} asset=${item.asset_id ?? '-'} url=${item.canonical_url ?? '-'}`,
);
}
console.log(`assets: ${trace.assets.length}`);
for (const item of trace.assets) {
console.log(
`- ${item.id} ${item.workspace_relative_path ?? '-'} status=${item.status} size=${item.size_bytes ?? 0}`,
);
}
}
const args = parseArgs(process.argv);
const pool = createPoolFromEnv();
try {
const trace = await loadTrace(pool, args);
if (args.json) console.log(JSON.stringify(trace, null, 2));
else printTrace(trace);
} finally {
await pool.end();
}