mindspace: version runtime contracts and audit packages
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/<owner>/public/page.html
|
||||
```
|
||||
|
||||
启动后 Portal 日志应出现:`[Portal] Runtime profile: MEMIND_RUNTIME_PROFILE=local, MINDSPACE_SERVER_ADAPTER=local, ...`
|
||||
|
||||
**禁止:** 把 103 生产 `.env`、RDS 连接串、`MINDSPACE_REMOTE_AUTH_TOKEN` 生产值复制进本机 Git 仓库。
|
||||
|
||||
@@ -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/<owner>/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`
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export async function bootstrapPortalDomainServices({
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
mindSpaceRuntimeAdapter.assertReady?.();
|
||||
await mindSpaceRuntimeAdapter.assertReady?.();
|
||||
|
||||
const mindSpaceServiceFacade =
|
||||
mindSpaceRuntimeAdapter.serviceFacade;
|
||||
|
||||
Reference in New Issue
Block a user