diff --git a/docs/103-runtime-topology.md b/docs/103-runtime-topology.md index 2f4ab0d..1ce6517 100644 --- a/docs/103-runtime-topology.md +++ b/docs/103-runtime-topology.md @@ -68,6 +68,11 @@ not be reused. `/Users/john/MindSpace` is the production MindSpace service root. Do not treat `/Users/john/Project/Memind/MindSpace` as the canonical MindSpace service root after the split. +`/health` and `/mindspace/v1/contract` expose the MindSpace service contract +version and build metadata. Production release scripts must reject a service +whose contract version is stale, whose required authority bindings are missing, +or whose `gitSha` does not match the runtime artifact manifest. + ## goosed 103 goosed runs in Colima/Docker, not as native launchd processes. diff --git a/docs/local-dev.md b/docs/local-dev.md index 9fa22e0..69bfaeb 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -133,6 +133,18 @@ runtime artifact。它会读取本机配置的开发数据库并创建唯一的 `split-service-smoke-*` session/package 记录,退出时会清理对应 package/artifact,并把临时文件根删除。 +remote adapter 会在 Portal 启动时校验 `/mindspace/v1/contract` 的 +`contractVersion`、required capabilities 与关键 binding。如果本机 +`8082` 仍是旧 MindSpace runtime,Portal 会 fail-fast,而不是等到 +页面交付时才出现 `Unknown binding`。 + +排查 package / artifact / public URL 链路: + +```bash +npm run audit:conversation-packages -- --limit 100 +npm run trace:mindspace-artifact -- --public-url http://127.0.0.1:5173/MindSpace//public/page.html +``` + 启动后 Portal 日志应出现:`[Portal] Runtime profile: MEMIND_RUNTIME_PROFILE=local, MINDSPACE_SERVER_ADAPTER=local, ...` **禁止:** 把 103 生产 `.env`、RDS 连接串、`MINDSPACE_REMOTE_AUTH_TOKEN` 生产值复制进本机 Git 仓库。 diff --git a/docs/mindspace-seamless-migration-runbook.md b/docs/mindspace-seamless-migration-runbook.md index 8d467d3..bd071ea 100644 --- a/docs/mindspace-seamless-migration-runbook.md +++ b/docs/mindspace-seamless-migration-runbook.md @@ -47,6 +47,9 @@ Phase A is complete when: - Shared adapter contract - Local and remote adapter selection - Remote transport with auth token, timeout, and configurable operation path +- Remote contract fail-fast: Portal rejects a standalone MindSpace service + whose `/mindspace/v1/contract` is below the required contract version or + missing required authority bindings - Remote mode skips Memind-side generated-page workspace sync; background jobs stay on the standalone MindSpace runtime ### Standalone MindSpace service @@ -57,7 +60,9 @@ Path: Responsibilities: - bootstrap DB-backed local MindSpace services - expose `POST /mindspace/v1/adapter/:binding/:method` -- expose `/health` and `/mindspace/v1/contract` +- expose `/health` and `/mindspace/v1/contract`; both include the service + contract version, and `/contract` also includes required capabilities, + bindings, `buildId`, `gitSha`, and `builtAt` - manage publication cleanup - manage optional workspace maintenance - manage optional agent worker @@ -147,6 +152,19 @@ The smoke uses the configured local development database to create a unique `split-service-smoke-*` session/package and removes those package/artifact rows plus its temporary filesystem root before exit. +Contract and artifact diagnostics: + +```bash +npm run audit:conversation-packages -- --limit 100 +npm run trace:mindspace-artifact -- --package-id cp_20260727_16 +npm run trace:mindspace-artifact -- --public-url http://127.0.0.1:5173/MindSpace//public/page.html +``` + +`audit:conversation-packages -- --repair` is intentionally narrow: it only +inserts missing `public_html` artifact records when a `generated_file` +artifact already points to a `public/*.html` `h5_assets` row. It does not +rewrite physical files or change publication status. + Current verified local user: - `1c99b83b-0454-474f-a5d2-129d34506a32` diff --git a/mindspace-conversation-package-audit.mjs b/mindspace-conversation-package-audit.mjs new file mode 100644 index 0000000..79fa494 --- /dev/null +++ b/mindspace-conversation-package-audit.mjs @@ -0,0 +1,250 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; + +function text(value) { + return String(value ?? '').trim(); +} + +function normalizeRelativePath(value) { + return text(value).replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function isPublicHtmlPath(value) { + const normalized = normalizeRelativePath(value); + return ( + normalized.startsWith('public/') && + normalized.toLowerCase().endsWith('.html') + ); +} + +function issue(code, message, details = {}) { + return { code, message, ...details }; +} + +export function buildPublicHtmlArtifactId({ + userId, + sessionId, + relativePath, +}) { + const hash = crypto + .createHash('sha256') + .update( + `${text(userId)}:${text(sessionId)}:${normalizeRelativePath(relativePath)}`, + ) + .digest('hex') + .slice(0, 16); + return `ca_public_html_${hash}`; +} + +function artifactHasReadableReference(artifact) { + return Boolean( + text(artifact?.storage_key) || + text(artifact?.storageKey) || + text(artifact?.canonical_url) || + text(artifact?.canonicalUrl), + ); +} + +function artifactAssetId(artifact) { + return text(artifact?.asset_id ?? artifact?.assetId); +} + +function artifactKind(artifact) { + return text(artifact?.artifact_kind ?? artifact?.artifactKind); +} + +function artifactDisplayName(artifact) { + return text(artifact?.display_name ?? artifact?.displayName); +} + +function assetRelativePath(asset) { + return normalizeRelativePath( + asset?.workspace_relative_path ?? + asset?.workspaceRelativePath, + ); +} + +function assetSize(asset) { + const value = Number(asset?.size_bytes ?? asset?.sizeBytes); + return Number.isFinite(value) ? value : null; +} + +function assetUpdatedAt(asset, fallback) { + const value = Number(asset?.updated_at ?? asset?.updatedAt); + return Number.isFinite(value) ? value : fallback; +} + +function packageUserId(packageRecord) { + return text(packageRecord?.user_id ?? packageRecord?.userId); +} + +function packageSessionId(packageRecord) { + return text(packageRecord?.session_id ?? packageRecord?.sessionId); +} + +function packageId(packageRecord) { + return text(packageRecord?.id ?? packageRecord?.packageId); +} + +function publicHtmlKey(relativePath) { + return path.posix.basename(normalizeRelativePath(relativePath)); +} + +function canonicalPublicUrl({ + publicBaseUrl, + userId, + relativePath, +}) { + const base = text(publicBaseUrl).replace(/\/+$/, ''); + const owner = encodeURIComponent(text(userId)); + const encodedRelative = normalizeRelativePath(relativePath) + .split('/') + .map(encodeURIComponent) + .join('/'); + return `${base}/MindSpace/${owner}/${encodedRelative}`; +} + +export function auditConversationPackages({ + packages = [], + artifacts = [], + assets = [], + publicBaseUrl = 'http://127.0.0.1:5173', +} = {}) { + const issues = []; + const repairs = []; + const artifactsByPackage = new Map(); + const assetsById = new Map( + assets.map((asset) => [text(asset?.id), asset]), + ); + + for (const artifact of artifacts) { + const id = text(artifact?.package_id ?? artifact?.packageId); + if (!id) continue; + const list = artifactsByPackage.get(id) ?? []; + list.push(artifact); + artifactsByPackage.set(id, list); + } + + for (const packageRecord of packages) { + const id = packageId(packageRecord); + const userId = packageUserId(packageRecord); + const sessionId = packageSessionId(packageRecord); + const packageArtifacts = artifactsByPackage.get(id) ?? []; + if (packageArtifacts.length === 0) { + issues.push( + issue( + 'package_without_artifacts', + `package has no artifacts: ${id}`, + { packageId: id, userId, sessionId }, + ), + ); + continue; + } + + const publicHtmlArtifactNames = new Set( + packageArtifacts + .filter((artifact) => artifactKind(artifact) === 'public_html') + .map((artifact) => artifactDisplayName(artifact)) + .filter(Boolean), + ); + + for (const artifact of packageArtifacts) { + const artifactId = text(artifact?.id); + if (!artifactHasReadableReference(artifact)) { + issues.push( + issue( + 'artifact_without_backing_reference', + `artifact has no storageKey or canonicalUrl: ${artifactId}`, + { packageId: id, artifactId }, + ), + ); + } + + const assetId = artifactAssetId(artifact); + const asset = assetId ? assetsById.get(assetId) : null; + if (assetId && !asset) { + issues.push( + issue( + 'artifact_asset_missing', + `artifact points to a missing asset: ${assetId}`, + { packageId: id, artifactId, assetId }, + ), + ); + continue; + } + + const relativePath = asset ? assetRelativePath(asset) : ''; + if ( + artifactKind(artifact) === 'generated_file' && + isPublicHtmlPath(relativePath) + ) { + const displayName = publicHtmlKey(relativePath); + if (!publicHtmlArtifactNames.has(displayName)) { + const repair = { + action: 'insert_public_html_artifact', + packageId: id, + userId, + sessionId, + sourceArtifactId: artifactId, + sourceAssetId: assetId, + relativePath, + artifactId: buildPublicHtmlArtifactId({ + userId, + sessionId, + relativePath, + }), + displayName, + mimeType: 'text/html', + sizeBytes: + assetSize(asset) ?? + (Number(artifact?.size_bytes ?? 0) || null), + canonicalUrl: canonicalPublicUrl({ + publicBaseUrl, + userId, + relativePath, + }), + sortOrder: assetUpdatedAt( + asset, + Number(artifact?.created_at ?? Date.now()), + ), + }; + issues.push( + issue( + 'missing_public_html_artifact', + `generated public HTML is missing public_html artifact: ${relativePath}`, + { + packageId: id, + artifactId, + assetId, + relativePath, + repair, + }, + ), + ); + repairs.push(repair); + } + } + } + } + + return { + ok: issues.length === 0, + issues, + repairs, + summary: { + packageCount: packages.length, + artifactCount: artifacts.length, + assetCount: assets.length, + issueCount: issues.length, + repairCount: repairs.length, + }, + }; +} + +export const conversationPackageAuditInternals = { + artifactHasReadableReference, + canonicalPublicUrl, + isPublicHtmlPath, + normalizeRelativePath, + publicHtmlKey, +}; diff --git a/mindspace-conversation-package-audit.test.mjs b/mindspace-conversation-package-audit.test.mjs new file mode 100644 index 0000000..827a6a6 --- /dev/null +++ b/mindspace-conversation-package-audit.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + auditConversationPackages, + buildPublicHtmlArtifactId, +} from './mindspace-conversation-package-audit.mjs'; + +test('auditConversationPackages reports empty packages', () => { + const result = auditConversationPackages({ + packages: [ + { + id: 'cp_session-1', + user_id: 'user-1', + session_id: 'session-1', + }, + ], + artifacts: [], + assets: [], + }); + + assert.equal(result.ok, false); + assert.equal(result.issues[0].code, 'package_without_artifacts'); +}); + +test('auditConversationPackages plans public_html repair for generated public HTML assets', () => { + const asset = { + id: 'asset-1', + workspace_relative_path: 'public/report.html', + size_bytes: 42, + updated_at: 1234, + }; + const result = auditConversationPackages({ + publicBaseUrl: 'https://example.com', + packages: [ + { + id: 'cp_session-1', + user_id: 'user-1', + session_id: 'session-1', + }, + ], + artifacts: [ + { + id: 'ca_workspace_asset-1', + package_id: 'cp_session-1', + asset_id: 'asset-1', + artifact_kind: 'generated_file', + storage_key: + 'workspace://user-1/public/report.html', + display_name: 'report.html', + size_bytes: 42, + created_at: 1000, + }, + ], + assets: [asset], + }); + + assert.equal(result.ok, false); + assert.equal(result.repairs.length, 1); + assert.equal( + result.repairs[0].artifactId, + buildPublicHtmlArtifactId({ + userId: 'user-1', + sessionId: 'session-1', + relativePath: 'public/report.html', + }), + ); + assert.equal( + result.repairs[0].canonicalUrl, + 'https://example.com/MindSpace/user-1/public/report.html', + ); +}); + +test('auditConversationPackages accepts paired generated_file and public_html artifacts', () => { + const result = auditConversationPackages({ + packages: [ + { + id: 'cp_session-1', + user_id: 'user-1', + session_id: 'session-1', + }, + ], + artifacts: [ + { + id: 'ca_workspace_asset-1', + package_id: 'cp_session-1', + asset_id: 'asset-1', + artifact_kind: 'generated_file', + storage_key: + 'workspace://user-1/public/report.html', + display_name: 'report.html', + }, + { + id: 'ca_public_html_report', + package_id: 'cp_session-1', + artifact_kind: 'public_html', + display_name: 'report.html', + canonical_url: + 'https://example.com/MindSpace/user-1/public/report.html', + }, + ], + assets: [ + { + id: 'asset-1', + workspace_relative_path: 'public/report.html', + }, + ], + }); + + assert.equal(result.ok, true); + assert.equal(result.repairs.length, 0); +}); + +test('auditConversationPackages reports artifacts pointing at missing assets', () => { + const result = auditConversationPackages({ + packages: [ + { + id: 'cp_session-1', + user_id: 'user-1', + session_id: 'session-1', + }, + ], + artifacts: [ + { + id: 'ca_missing_asset', + package_id: 'cp_session-1', + asset_id: 'asset-missing', + artifact_kind: 'generated_file', + canonical_url: '/api/mindspace/v1/assets/asset-missing/download', + }, + ], + assets: [], + }); + + assert.equal(result.ok, false); + assert.equal(result.issues[0].code, 'artifact_asset_missing'); +}); diff --git a/mindspace-remote-server-adapter.mjs b/mindspace-remote-server-adapter.mjs index 92aa6f6..84d0e29 100644 --- a/mindspace-remote-server-adapter.mjs +++ b/mindspace-remote-server-adapter.mjs @@ -1,6 +1,9 @@ import { MINDSPACE_SERVER_ADAPTER_BINDINGS, MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS, + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, } from './mindspace-server-adapter-contract.mjs'; function trimTrailingSlash(value) { @@ -27,6 +30,15 @@ function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`; } +function buildRemoteContractUrl(endpoint, contractPath = '/mindspace/v1/contract') { + const base = resolveRemoteAdapterEndpoint(endpoint); + if (!base) { + throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL'); + } + const normalizedPath = normalizeOperationBasePath(contractPath); + return `${base}${normalizedPath}`; +} + function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) { const error = new Error( payload?.message ?? @@ -88,6 +100,114 @@ async function invokeRemoteOperation({ } } +function formatContractIssue(issue) { + if (typeof issue === 'string') return issue; + return JSON.stringify(issue); +} + +export function validateMindSpaceRemoteContract( + contract, + { + minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS, + requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, + } = {}, +) { + const issues = []; + const version = Number(contract?.contractVersion ?? 0); + if (!Number.isFinite(version) || version < minContractVersion) { + issues.push( + `contractVersion ${contract?.contractVersion ?? '(missing)'} is below required ${minContractVersion}`, + ); + } + const bindings = contract?.bindings ?? {}; + for (const [bindingKey, methods] of Object.entries(requiredBindings ?? {})) { + const exposed = bindings[bindingKey]; + if (!Array.isArray(exposed)) { + issues.push(`missing binding ${bindingKey}`); + continue; + } + for (const method of methods) { + if (!exposed.includes(method)) { + issues.push(`missing method ${bindingKey}.${method}`); + } + } + } + const capabilities = new Set( + Array.isArray(contract?.requiredCapabilities) + ? contract.requiredCapabilities + : [], + ); + for (const capability of requiredCapabilities ?? []) { + if (!capabilities.has(capability)) { + issues.push(`missing capability ${capability}`); + } + } + if (issues.length > 0) { + const error = new Error( + `MindSpace remote contract is incompatible: ${issues + .map(formatContractIssue) + .join('; ')}`, + ); + error.code = 'mindspace_contract_incompatible'; + error.details = { + issues, + contractVersion: contract?.contractVersion ?? null, + buildId: contract?.buildId ?? null, + gitSha: contract?.gitSha ?? null, + }; + throw error; + } + return contract; +} + +async function fetchRemoteContract({ + endpoint, + contractPath, + fetchFn, + authToken, + timeoutMs, +}) { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = + controller && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => controller.abort(new Error('MindSpace remote contract request timed out')), timeoutMs) + : null; + try { + const response = await fetchFn(buildRemoteContractUrl(endpoint, contractPath), { + method: 'GET', + headers: { + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + signal: controller?.signal, + }); + const bodyText = await response.text().catch(() => ''); + let payload = null; + if (bodyText) { + try { + payload = JSON.parse(bodyText); + } catch { + payload = null; + } + } + if (!response.ok) { + const error = new Error( + `MindSpace remote contract check failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`, + ); + error.code = 'mindspace_contract_unavailable'; + throw error; + } + if (!payload) { + const error = new Error('MindSpace remote contract response must be valid JSON'); + error.code = 'mindspace_contract_invalid_json'; + throw error; + } + return payload; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + function createRemoteBindingProxy({ endpoint, bindingKey, @@ -128,6 +248,10 @@ export function createMindSpaceRemoteServerAdapter({ endpoint = '', authToken = '', operationBasePath = '/mindspace/v1/adapter', + contractPath = '/mindspace/v1/contract', + minContractVersion = MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + requiredBindings = MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS, + requiredCapabilities = MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, timeoutMs = 15_000, fetchFn = globalThis.fetch?.bind(globalThis), } = {}) { @@ -157,15 +281,31 @@ export function createMindSpaceRemoteServerAdapter({ implementationStatus: 'scaffold', endpoint: endpointUrl, operationBasePath: normalizedOperationBasePath, + contractPath: normalizeOperationBasePath(contractPath), timeoutMs, storageRoot: null, publicBaseUrl: null, storageAdapter: null, ...bindings, - assertReady() { + async assertReady() { if (!endpointUrl) { throw new Error('MindSpace remote server adapter requires MINDSPACE_REMOTE_BASE_URL'); } + const contract = await fetchRemoteContract({ + endpoint: endpointUrl, + contractPath, + fetchFn: invoke, + authToken, + timeoutMs, + }); + validateMindSpaceRemoteContract(contract, { + minContractVersion, + requiredBindings, + requiredCapabilities, + }); + logger.log?.( + `MindSpace remote contract ok (version=${contract.contractVersion}, build=${contract.buildId ?? 'unknown'}, git=${contract.gitSha ?? 'unknown'})`, + ); return true; }, startBackgroundJobs() { @@ -182,7 +322,10 @@ export function createMindSpaceRemoteServerAdapter({ } export const mindspaceRemoteServerAdapterInternals = { + buildRemoteContractUrl, buildRemoteOperationUrl, + fetchRemoteContract, invokeRemoteOperation, normalizeOperationBasePath, + validateMindSpaceRemoteContract, }; diff --git a/mindspace-remote-server-adapter.test.mjs b/mindspace-remote-server-adapter.test.mjs index 1123854..d03b7e9 100644 --- a/mindspace-remote-server-adapter.test.mjs +++ b/mindspace-remote-server-adapter.test.mjs @@ -3,7 +3,13 @@ import test from 'node:test'; import { createMindSpaceRemoteServerAdapter, mindspaceRemoteServerAdapterInternals, + validateMindSpaceRemoteContract, } from './mindspace-remote-server-adapter.mjs'; +import { + MINDSPACE_SERVER_ADAPTER_BINDINGS, + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, +} from './mindspace-server-adapter-contract.mjs'; test('buildRemoteOperationUrl normalizes endpoint and encodes operation path', () => { assert.equal( @@ -17,13 +23,93 @@ test('buildRemoteOperationUrl normalizes endpoint and encodes operation path', ( ); }); -test('createMindSpaceRemoteServerAdapter validates endpoint at startup', () => { +test('createMindSpaceRemoteServerAdapter validates endpoint at startup', async () => { const adapter = createMindSpaceRemoteServerAdapter({ fetchFn: async () => { throw new Error('should not fetch'); }, }); - assert.throws(() => adapter.assertReady(), /MINDSPACE_REMOTE_BASE_URL/); + await assert.rejects(() => adapter.assertReady(), /MINDSPACE_REMOTE_BASE_URL/); +}); + +test('createMindSpaceRemoteServerAdapter validates remote contract before startup succeeds', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + contractVersion: + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS, + requiredCapabilities: + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, + buildId: 'mindspace-test', + gitSha: 'abc123', + }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + + await assert.doesNotReject(() => adapter.assertReady()); + assert.equal( + calls[0].url, + 'https://mindspace.example.com/mindspace/v1/contract', + ); + assert.equal(calls[0].init.method, 'GET'); + assert.equal( + calls[0].init.headers.Authorization, + 'Bearer secret-token', + ); +}); + +test('createMindSpaceRemoteServerAdapter rejects stale remote contracts', async () => { + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + fetchFn: async () => ({ + ok: true, + status: 200, + async text() { + return JSON.stringify({ + contractVersion: 1, + bindings: { + assetService: ['renderAssetPreview'], + }, + requiredCapabilities: [], + }); + }, + }), + logger: { log() {}, warn() {}, error() {} }, + }); + + await assert.rejects( + () => adapter.assertReady(), + (error) => + error?.code === 'mindspace_contract_incompatible' && + /workspacePublicationDeliveryService/.test(error.message), + ); +}); + +test('validateMindSpaceRemoteContract reports missing capabilities and methods', () => { + assert.throws( + () => + validateMindSpaceRemoteContract({ + contractVersion: + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + bindings: { + chatSaveService: ['createSharedHtml'], + }, + requiredCapabilities: ['chat-save-authority'], + }), + /missing method chatSaveService\.materializeWorkspaceHtml/, + ); }); test('createMindSpaceRemoteServerAdapter proxies server binding methods through fetch transport', async () => { diff --git a/mindspace-runtime-config.mjs b/mindspace-runtime-config.mjs index 99f8fc0..63cef09 100644 --- a/mindspace-runtime-config.mjs +++ b/mindspace-runtime-config.mjs @@ -149,11 +149,12 @@ export function createMindSpaceLocalRuntime({ logger = console, } = {}) { const config = resolveMindSpaceRuntimeConfig(h5Root, env); + const storageAdapter = createLocalMindSpaceStorageAdapter(config.storageRoot); return { ...config, - storageAdapter: createLocalMindSpaceStorageAdapter(config.storageRoot), + storageAdapter, serviceFacade: createMindSpaceServiceFacade({ - storageAdapter: createLocalMindSpaceStorageAdapter(config.storageRoot), + storageAdapter, publicBaseUrl: config.publicBaseUrl, conversationPackageBackfill, conversationPackagePublicHtmlHydrator, diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index 69f2860..03fbe85 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -1,3 +1,12 @@ +export const MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION = 2; + +export const MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES = Object.freeze([ + 'chat-save-authority', + 'public-finish-authority', + 'workspace-publication-delivery-authority', + 'scoped-workspace-tools', +]); + export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ serviceFacade: Object.freeze(['prepareConversationPackageRead']), chatSaveService: Object.freeze([ @@ -119,6 +128,66 @@ export const MINDSPACE_SERVER_ADAPTER_BINDING_KEYS = Object.freeze( Object.keys(MINDSPACE_SERVER_ADAPTER_BINDINGS), ); +export const MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS = Object.freeze({ + chatSaveService: Object.freeze([ + 'createSharedHtml', + 'materializeWorkspaceHtml', + 'readWorkspaceHtml', + ]), + conversationArtifactService: Object.freeze([ + 'registerPublicHtmlArtifacts', + 'registerWorkspaceFileArtifact', + ]), + publicFinishService: Object.freeze([ + 'prepareWechatHtmlDelivery', + 'syncAfterFinish', + ]), + workspacePublicationDeliveryService: Object.freeze([ + 'resolveWorkspaceRequest', + 'validateRunDeliverables', + ]), + workspaceToolService: Object.freeze([ + 'readFile', + 'writeFile', + 'editFile', + 'publishPage', + 'writeBinaryFile', + ]), +}); + +export function createMindSpaceServerAdapterContractPayload({ + adapter = {}, + serviceMeta = {}, + env = process.env, +} = {}) { + return { + contractVersion: + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + service: 'mindspace-service', + adapterKind: adapter.kind ?? null, + implementationStatus: + adapter.implementationStatus ?? null, + buildId: + serviceMeta.buildId ?? + env.MINDSPACE_SERVICE_BUILD_ID ?? + null, + gitSha: + serviceMeta.gitSha ?? + env.MINDSPACE_SERVICE_GIT_SHA ?? + null, + builtAt: + serviceMeta.builtAt ?? + env.MINDSPACE_SERVICE_BUILT_AT ?? + null, + requiredCapabilities: + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, + requiredBindings: + MINDSPACE_SERVER_ADAPTER_REQUIRED_BINDINGS, + bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS, + bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, + }; +} + export function assertMindSpaceServerAdapterBindingContract(bindingKey, service) { const methods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey]; if (!methods) { diff --git a/mindspace-server-adapter.test.mjs b/mindspace-server-adapter.test.mjs index 4a796df..5231055 100644 --- a/mindspace-server-adapter.test.mjs +++ b/mindspace-server-adapter.test.mjs @@ -5,6 +5,11 @@ import { createMindSpaceServerAdapter, resolveMindSpaceServerAdapterKind, } from './mindspace-server-adapter.mjs'; +import { + MINDSPACE_SERVER_ADAPTER_BINDINGS, + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, +} from './mindspace-server-adapter-contract.mjs'; test('resolveMindSpaceServerAdapterKind defaults to local and rejects unknown kinds', () => { assert.equal(resolveMindSpaceServerAdapterKind({}), 'local'); @@ -39,19 +44,32 @@ test('createMindSpaceServerAdapter returns a contract-complete local adapter by assert.equal(assertMindSpaceServerAdapterContract(adapter), adapter); }); -test('createMindSpaceServerAdapter exposes a remote stub that fails fast when selected', () => { +test('createMindSpaceServerAdapter exposes a remote stub that validates contract when selected', async () => { const adapter = createMindSpaceServerAdapter({ env: { MINDSPACE_SERVER_ADAPTER: 'remote', MINDSPACE_REMOTE_BASE_URL: 'https://mindspace.example.com', }, + fetchFn: async () => ({ + ok: true, + status: 200, + async text() { + return JSON.stringify({ + contractVersion: + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS, + requiredCapabilities: + MINDSPACE_SERVER_ADAPTER_REQUIRED_CAPABILITIES, + }); + }, + }), logger: { log() {}, warn() {}, error() {} }, }); assert.equal(adapter.kind, 'remote'); assert.equal(adapter.implementationStatus, 'scaffold'); assert.equal(assertMindSpaceServerAdapterContract(adapter), adapter); - assert.equal(adapter.assertReady(), true); + assert.equal(await adapter.assertReady(), true); assert.deepEqual(adapter.startBackgroundJobs(), { publicationCleanup: null, agentWorker: null, diff --git a/mindspace-service.mjs b/mindspace-service.mjs index c8dd884..f964176 100644 --- a/mindspace-service.mjs +++ b/mindspace-service.mjs @@ -9,17 +9,21 @@ import { createMindSpacePublicUrl, normalizeMindSpacePublicRelativePath, } from './mindspace-canonical-url.mjs'; -import { normalizeMindSpaceStorageKey } from './mindspace-storage-adapter.mjs'; +import { + assertMindSpaceStorageAdapterContract, + normalizeMindSpaceStorageKey, +} from './mindspace-storage-adapter.mjs'; function serviceError(message, code, details = {}) { return Object.assign(new Error(message), { code, ...details }); } function requireStorageAdapter(storageAdapter) { - if (!storageAdapter || typeof storageAdapter.putObject !== 'function') { + try { + return assertMindSpaceStorageAdapterContract(storageAdapter); + } catch (error) { throw serviceError('MindSpace storage adapter is required', 'missing_mindspace_storage_adapter'); } - return storageAdapter; } function packageStorageKey(packageRecord, relativePath) { diff --git a/mindspace-service/mindspace-rpc-server.mjs b/mindspace-service/mindspace-rpc-server.mjs index b501a18..5f783ef 100644 --- a/mindspace-service/mindspace-rpc-server.mjs +++ b/mindspace-service/mindspace-rpc-server.mjs @@ -151,8 +151,9 @@ export async function createMindSpaceRpcRequestHandler({ serviceMeta = {}, } = {}) { const { + createMindSpaceServerAdapterContractPayload, MINDSPACE_SERVER_ADAPTER_BINDINGS, - MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, } = await loadContract(env); const operationBasePath = String( serviceMeta.operationBasePath ?? env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter', @@ -199,6 +200,16 @@ export async function createMindSpaceRpcRequestHandler({ service: 'mindspace-service', adapterKind: adapter.kind, implementationStatus: adapter.implementationStatus, + contractVersion: + MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION, + buildId: + serviceMeta.buildId ?? + env.MINDSPACE_SERVICE_BUILD_ID ?? + null, + gitSha: + serviceMeta.gitSha ?? + env.MINDSPACE_SERVICE_GIT_SHA ?? + null, operationBasePath, mcpOperationBasePath, mcpScopedToolsEnabled: @@ -207,10 +218,15 @@ export async function createMindSpaceRpcRequestHandler({ }); } if (req.method === 'GET' && url.pathname === '/mindspace/v1/contract') { - return json(res, 200, { - bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS, - bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, - }); + return json( + res, + 200, + createMindSpaceServerAdapterContractPayload({ + adapter, + serviceMeta, + env, + }), + ); } if ( req.method === 'POST' && diff --git a/mindspace-service/mindspace-rpc-server.test.mjs b/mindspace-service/mindspace-rpc-server.test.mjs index a43e16b..1b1777e 100644 --- a/mindspace-service/mindspace-rpc-server.test.mjs +++ b/mindspace-service/mindspace-rpc-server.test.mjs @@ -320,6 +320,11 @@ test('health and contract endpoints are exposed without auth', async () => { env: { MINDSPACE_MEMIND_ROOT: '..', }, + serviceMeta: { + buildId: 'mindspace-test-build', + gitSha: 'abc123', + builtAt: '2026-07-27T00:00:00.000Z', + }, }); const health = await runRequest(handler, { path: '/health' }); @@ -327,7 +332,23 @@ test('health and contract endpoints are exposed without auth', async () => { assert.equal(health.statusCode, 200); assert.equal(health.body.ok, true); + assert.equal(health.body.contractVersion, 2); + assert.equal(health.body.buildId, 'mindspace-test-build'); assert.equal(contract.statusCode, 200); + assert.equal(contract.body.contractVersion, 2); + assert.equal(contract.body.service, 'mindspace-service'); + assert.equal(contract.body.buildId, 'mindspace-test-build'); + assert.equal(contract.body.gitSha, 'abc123'); + assert.ok( + contract.body.requiredCapabilities.includes( + 'workspace-publication-delivery-authority', + ), + ); + assert.ok( + contract.body.requiredBindings.workspaceToolService.includes( + 'publishPage', + ), + ); assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview')); assert.ok( contract.body.bindings.chatSaveService.includes( diff --git a/mindspace-service/server.mjs b/mindspace-service/server.mjs index 117f8e1..ac6cb22 100644 --- a/mindspace-service/server.mjs +++ b/mindspace-service/server.mjs @@ -1,9 +1,27 @@ import { bootstrapMindSpaceService } from './mindspace-service-bootstrap.mjs'; import { startMindSpaceRpcServer } from './mindspace-rpc-server.mjs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const logger = console; +async function readBuildInfo() { + try { + const raw = await fs.readFile( + path.join(__dirname, 'build-info.json'), + 'utf8', + ); + return JSON.parse(raw); + } catch { + return {}; + } +} + async function main() { + const buildInfo = await readBuildInfo(); const service = await bootstrapMindSpaceService({ env: process.env, logger }); const rpc = await startMindSpaceRpcServer({ adapter: service.adapter, @@ -11,6 +29,9 @@ async function main() { logger, serviceMeta: { operationBasePath: service.runtime.remote.operationBasePath, + buildId: buildInfo.buildId, + gitSha: buildInfo.gitSha, + builtAt: buildInfo.builtAt, }, }); diff --git a/mindspace-storage-adapter.mjs b/mindspace-storage-adapter.mjs index 1bec7de..ccdc647 100644 --- a/mindspace-storage-adapter.mjs +++ b/mindspace-storage-adapter.mjs @@ -2,10 +2,44 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; +export const MINDSPACE_STORAGE_ADAPTER_CONTRACT_VERSION = 1; + +export const MINDSPACE_STORAGE_ADAPTER_REQUIRED_METHODS = Object.freeze([ + 'putObject', + 'getObject', + 'statObject', + 'listObjects', + 'listPrefix', + 'deleteObject', + 'copyObject', + 'createReadStream', + 'createWriteStream', + 'getSignedUrl', +]); + function storageError(message, code, details = {}) { return Object.assign(new Error(message), { code, ...details }); } +export function assertMindSpaceStorageAdapterContract(adapter) { + if (!adapter || typeof adapter !== 'object') { + throw storageError( + 'MindSpace storage adapter is required', + 'missing_storage_adapter', + ); + } + for (const method of MINDSPACE_STORAGE_ADAPTER_REQUIRED_METHODS) { + if (typeof adapter[method] !== 'function') { + throw storageError( + `MindSpace storage adapter is missing ${method}()`, + 'invalid_storage_adapter', + { method }, + ); + } + } + return adapter; +} + export function normalizeMindSpaceStorageKey(key) { const raw = String(key ?? '').trim(); if (!raw) { @@ -125,6 +159,10 @@ export function createLocalMindSpaceStorageAdapter(storageRoot) { return results.sort((a, b) => a.key.localeCompare(b.key)); }, + async listPrefix(prefix = '') { + return this.listObjects(prefix); + }, + async copyObject(sourceKey, targetKey) { const source = resolveStoragePath(root, sourceKey); const target = resolveStoragePath(root, targetKey); diff --git a/mindspace-storage-adapter.test.mjs b/mindspace-storage-adapter.test.mjs index 9528118..a19a282 100644 --- a/mindspace-storage-adapter.test.mjs +++ b/mindspace-storage-adapter.test.mjs @@ -4,11 +4,23 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { + assertMindSpaceStorageAdapterContract, createLocalMindSpaceStorageAdapter, + MINDSPACE_STORAGE_ADAPTER_CONTRACT_VERSION, normalizeMindSpaceStorageKey, resolveStoragePath, } from './mindspace-storage-adapter.mjs'; +test('storage adapter exposes a versioned contract', () => { + assert.equal(MINDSPACE_STORAGE_ADAPTER_CONTRACT_VERSION, 1); + const adapter = createLocalMindSpaceStorageAdapter('/tmp/mindspace-storage-contract'); + assert.equal(assertMindSpaceStorageAdapterContract(adapter), adapter); + assert.throws( + () => assertMindSpaceStorageAdapterContract({ putObject() {} }), + (error) => error.code === 'invalid_storage_adapter', + ); +}); + test('normalizeMindSpaceStorageKey rejects absolute paths and traversal', () => { assert.equal(normalizeMindSpaceStorageKey('users/u1/file.txt'), 'users/u1/file.txt'); assert.equal(normalizeMindSpaceStorageKey('users/u1/./file.txt'), 'users/u1/file.txt'); @@ -59,6 +71,10 @@ test('local storage adapter writes, reads, stats, lists, copies, and deletes obj 'users/u1/conversations/s1/public/report.html', ], ); + assert.deepEqual( + (await adapter.listPrefix('users/u1/conversations/s1/generated')).map((item) => item.key), + ['users/u1/conversations/s1/generated/report-copy.html'], + ); await adapter.deleteObject('users/u1/conversations/s1/public/report.html'); assert.equal((await adapter.statObject('users/u1/conversations/s1/public/report.html')).exists, false); diff --git a/package.json b/package.json index 2f6a8fc..28ba32a 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", "check:mindspace-cover": "node scripts/check-mindspace-cover.mjs", "demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs", + "audit:conversation-packages": "node scripts/audit-conversation-packages.mjs", + "trace:mindspace-artifact": "node scripts/trace-mindspace-artifact.mjs", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", "check:memory-v2": "node scripts/check-memory-v2-health.mjs", "canary:memory-v2-app": "node scripts/check-memory-v2-app-canary.mjs", @@ -75,7 +77,7 @@ "ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs", "migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", diff --git a/scripts/audit-conversation-packages.mjs b/scripts/audit-conversation-packages.mjs new file mode 100644 index 0000000..94964aa --- /dev/null +++ b/scripts/audit-conversation-packages.mjs @@ -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 ] [--session-id ] [--limit ] [--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(); +} diff --git a/scripts/build-mindspace-service-runtime.mjs b/scripts/build-mindspace-service-runtime.mjs index 35be28e..8aa343d 100644 --- a/scripts/build-mindspace-service-runtime.mjs +++ b/scripts/build-mindspace-service-runtime.mjs @@ -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() { diff --git a/scripts/release-mindspace-service-prod.sh b/scripts/release-mindspace-service-prod.sh index 60bd9f1..ea0c0f9 100644 --- a/scripts/release-mindspace-service-prod.sh +++ b/scripts/release-mindspace-service-prod.sh @@ -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" diff --git a/scripts/smoke-mindspace-split-service.mjs b/scripts/smoke-mindspace-split-service.mjs index 080ba51..9a67aa2 100644 --- a/scripts/smoke-mindspace-split-service.mjs +++ b/scripts/smoke-mindspace-split-service.mjs @@ -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(); diff --git a/scripts/trace-mindspace-artifact.mjs b/scripts/trace-mindspace-artifact.mjs new file mode 100644 index 0000000..36edf55 --- /dev/null +++ b/scripts/trace-mindspace-artifact.mjs @@ -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 | --package-id | --asset-id | --public-url ) [--user-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(); +} diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs index 630c10c..de97459 100644 --- a/server/portal-domain-services-bootstrap.mjs +++ b/server/portal-domain-services-bootstrap.mjs @@ -160,7 +160,7 @@ export async function bootstrapPortalDomainServices({ logger, }), ); - mindSpaceRuntimeAdapter.assertReady?.(); + await mindSpaceRuntimeAdapter.assertReady?.(); const mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade;