Record synced workspace artifacts in packages
This commit is contained in:
@@ -301,6 +301,7 @@ export function createAssetService(pool, options = {}) {
|
||||
h5Root,
|
||||
maxFileBytes,
|
||||
idFactory,
|
||||
conversationPackageRegistry,
|
||||
});
|
||||
|
||||
const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
|
||||
|
||||
@@ -386,6 +386,7 @@ export async function syncPublicHtmlAfterFinish({
|
||||
currentUser,
|
||||
publishDir,
|
||||
syncWorkspaceAssets,
|
||||
sessionId = null,
|
||||
} = {}) {
|
||||
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
||||
return { materialized: [], skipped: [], synced: false };
|
||||
@@ -395,7 +396,7 @@ export async function syncPublicHtmlAfterFinish({
|
||||
const docxSync = syncPublicDocxDownloads({ publishDir });
|
||||
let synced = false;
|
||||
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
|
||||
await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' });
|
||||
await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId });
|
||||
synced = true;
|
||||
}
|
||||
return { materialized, skipped, synced, docxSync };
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
materializeMissingPublicHtmlWrites,
|
||||
materializePublicHtmlWritesFromSessionEvent,
|
||||
syncPublicDocxDownloads,
|
||||
syncPublicHtmlAfterFinish,
|
||||
} from './mindspace-public-finish-sync.mjs';
|
||||
|
||||
const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' };
|
||||
@@ -151,6 +152,46 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too
|
||||
}
|
||||
});
|
||||
|
||||
test('syncPublicHtmlAfterFinish forwards session source to workspace sync', async () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||
try {
|
||||
const calls = [];
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'write_file',
|
||||
arguments: {
|
||||
path: 'public/session-page.html',
|
||||
content: '<!doctype html><title>Session page</title>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await syncPublicHtmlAfterFinish({
|
||||
messages,
|
||||
currentUser: CURRENT_USER,
|
||||
publishDir,
|
||||
sessionId: 'session-1',
|
||||
syncWorkspaceAssets: async (...args) => {
|
||||
calls.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
assert.deepEqual(calls, [[CURRENT_USER.id, { categoryCode: 'public', sourceSessionId: 'session-1' }]]);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeMissingPublicHtmlWrites writes html from developer write tool calls', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,19 @@ function asNumber(value) {
|
||||
return Number(value ?? 0);
|
||||
}
|
||||
|
||||
function generatedArtifactKindForMime(mimeType, filename) {
|
||||
const normalizedMime = String(mimeType ?? '').toLowerCase();
|
||||
const normalizedName = String(filename ?? '').toLowerCase();
|
||||
if (normalizedMime.startsWith('image/')) return 'generated_image';
|
||||
if (normalizedName.endsWith('.docx') || normalizedName.endsWith('.doc')) return 'docx';
|
||||
if (normalizedName.endsWith('.pdf')) return 'pdf';
|
||||
return 'generated_file';
|
||||
}
|
||||
|
||||
function workspaceAssetDownloadUrl(assetId) {
|
||||
return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceRelativePath(relativePath) {
|
||||
const normalized = String(relativePath ?? '')
|
||||
.normalize('NFKC')
|
||||
@@ -100,7 +113,14 @@ export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) {
|
||||
return files;
|
||||
}
|
||||
|
||||
export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) {
|
||||
export function createWorkspaceAssetSync({
|
||||
pool,
|
||||
storageRoot,
|
||||
h5Root,
|
||||
maxFileBytes,
|
||||
idFactory,
|
||||
conversationPackageRegistry = null,
|
||||
}) {
|
||||
void storageRoot;
|
||||
const loadExistingAssets = async (userId, categoryId) => {
|
||||
const [rows] = await pool.query(
|
||||
@@ -221,7 +241,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
[buffer.length, now, category.space_id, userId],
|
||||
);
|
||||
await conn.commit();
|
||||
return { action: 'imported', assetId, filename: file.filename, checksum };
|
||||
return {
|
||||
action: 'imported',
|
||||
assetId,
|
||||
filename: file.filename,
|
||||
checksum,
|
||||
mimeType: detectedMimeType,
|
||||
sizeBytes: buffer.length,
|
||||
storageKey: finalStorageKey,
|
||||
categoryCode: category.category_code,
|
||||
};
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
@@ -323,7 +352,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
);
|
||||
}
|
||||
await conn.commit();
|
||||
return { action: 'updated', assetId: existing.id, filename: file.filename, checksum };
|
||||
return {
|
||||
action: 'updated',
|
||||
assetId: existing.id,
|
||||
filename: file.filename,
|
||||
checksum,
|
||||
mimeType: detectedMimeType,
|
||||
sizeBytes: buffer.length,
|
||||
storageKey: finalStorageKey,
|
||||
categoryCode: category.category_code,
|
||||
};
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
@@ -332,7 +370,40 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
}
|
||||
};
|
||||
|
||||
const syncCategory = async (userId, categoryCode) => {
|
||||
const registerWorkspaceArtifactForConversation = async (userId, source, result, now = Date.now()) => {
|
||||
const sessionId = String(source?.sessionId ?? source?.sourceSessionId ?? '').trim();
|
||||
if (!conversationPackageRegistry || !sessionId || !result?.assetId) return null;
|
||||
try {
|
||||
const packageRecord = await conversationPackageRegistry.ensurePackage({
|
||||
userId,
|
||||
sessionId,
|
||||
title: source?.title ?? null,
|
||||
now,
|
||||
});
|
||||
const artifact = await conversationPackageRegistry.recordArtifact({
|
||||
id: `ca_workspace_${result.assetId}`,
|
||||
packageId: packageRecord.id,
|
||||
artifactKind: generatedArtifactKindForMime(result.mimeType, result.filename),
|
||||
role: 'assistant',
|
||||
assetId: result.assetId,
|
||||
messageId: source?.messageId ?? source?.sourceMessageId ?? null,
|
||||
displayName: result.filename,
|
||||
mimeType: result.mimeType,
|
||||
sizeBytes: result.sizeBytes,
|
||||
storageKey: result.storageKey,
|
||||
canonicalUrl: workspaceAssetDownloadUrl(result.assetId),
|
||||
sortOrder: now,
|
||||
now,
|
||||
});
|
||||
await conversationPackageRegistry.writeManifestForSession({ userId, sessionId });
|
||||
return artifact;
|
||||
} catch (error) {
|
||||
console.warn('[MindSpace] workspace conversation artifact registration failed:', error?.message ?? error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncCategory = async (userId, categoryCode, source = {}) => {
|
||||
if (!h5Root) return { imported: 0, updated: 0, skipped: 0 };
|
||||
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
|
||||
const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode);
|
||||
@@ -369,11 +440,13 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
await updateWorkspaceFile(userId, category, existing, file, buffer);
|
||||
const result = await updateWorkspaceFile(userId, category, existing, file, buffer);
|
||||
await registerWorkspaceArtifactForConversation(userId, source, result);
|
||||
existing.checksum = checksum;
|
||||
updated += 1;
|
||||
} else {
|
||||
await importWorkspaceFile(userId, category, file, buffer);
|
||||
const result = await importWorkspaceFile(userId, category, file, buffer);
|
||||
await registerWorkspaceArtifactForConversation(userId, source, result);
|
||||
existingByName.set(file.filename, { checksum });
|
||||
imported += 1;
|
||||
}
|
||||
@@ -381,13 +454,14 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
return { imported, updated, skipped };
|
||||
};
|
||||
|
||||
const syncUserWorkspace = async (userId, { categoryCode } = {}) => {
|
||||
const syncUserWorkspace = async (userId, { categoryCode, sourceSessionId, sourceMessageId, title } = {}) => {
|
||||
const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const source = { sessionId: sourceSessionId, messageId: sourceMessageId, title };
|
||||
for (const code of codes) {
|
||||
const result = await syncCategory(userId, code);
|
||||
const result = await syncCategory(userId, code, source);
|
||||
imported += result.imported;
|
||||
updated += result.updated;
|
||||
skipped += result.skipped;
|
||||
@@ -398,6 +472,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
||||
return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles };
|
||||
}
|
||||
|
||||
export const workspaceSyncInternals = {
|
||||
generatedArtifactKindForMime,
|
||||
workspaceAssetDownloadUrl,
|
||||
};
|
||||
|
||||
export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) {
|
||||
if (!publishRoot || !syncUserWorkspaceByDirKey) return () => {};
|
||||
|
||||
|
||||
@@ -142,21 +142,65 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
|
||||
},
|
||||
};
|
||||
|
||||
const packageCalls = [];
|
||||
const conversationPackageRegistry = {
|
||||
async ensurePackage(input) {
|
||||
packageCalls.push(['ensurePackage', input]);
|
||||
return { id: 'cp-session-1' };
|
||||
},
|
||||
async recordArtifact(input) {
|
||||
packageCalls.push(['recordArtifact', input]);
|
||||
return input;
|
||||
},
|
||||
async writeManifestForSession(input) {
|
||||
packageCalls.push(['writeManifestForSession', input]);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
const sync = createWorkspaceAssetSync({
|
||||
pool,
|
||||
storageRoot,
|
||||
h5Root,
|
||||
maxFileBytes: 1024 * 1024,
|
||||
idFactory: () => `id-${++nextId}`,
|
||||
conversationPackageRegistry,
|
||||
});
|
||||
|
||||
const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' });
|
||||
const result = await sync.syncUserWorkspace('user-1', {
|
||||
categoryCode: 'oa',
|
||||
sourceSessionId: 'session-1',
|
||||
sourceMessageId: 'message-1',
|
||||
title: 'Workspace chat',
|
||||
});
|
||||
assert.equal(result.imported, 1);
|
||||
assert.equal(state.assets.length, 1);
|
||||
assert.equal(state.assets[0].original_filename, 'memo.txt');
|
||||
assert.ok(state.versions.length === 1);
|
||||
assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/);
|
||||
assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n');
|
||||
assert.deepEqual(packageCalls[0], [
|
||||
'ensurePackage',
|
||||
{
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
title: 'Workspace chat',
|
||||
now: packageCalls[0][1].now,
|
||||
},
|
||||
]);
|
||||
const artifactCall = packageCalls.find(([name]) => name === 'recordArtifact')?.[1];
|
||||
assert.equal(artifactCall.packageId, 'cp-session-1');
|
||||
assert.equal(artifactCall.artifactKind, 'generated_file');
|
||||
assert.equal(artifactCall.role, 'assistant');
|
||||
assert.equal(artifactCall.assetId, 'id-1');
|
||||
assert.equal(artifactCall.messageId, 'message-1');
|
||||
assert.equal(artifactCall.displayName, 'memo.txt');
|
||||
assert.equal(artifactCall.mimeType, 'text/plain');
|
||||
assert.equal(artifactCall.storageKey, 'workspace://user-1/oa/memo.txt');
|
||||
assert.equal(artifactCall.canonicalUrl, '/api/mindspace/v1/assets/id-1/download');
|
||||
assert.deepEqual(packageCalls.at(-1), [
|
||||
'writeManifestForSession',
|
||||
{ userId: 'user-1', sessionId: 'session-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('syncUserWorkspace imports nested workspace files into asset library', async () => {
|
||||
|
||||
@@ -4508,6 +4508,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
messages,
|
||||
currentUser: req.currentUser,
|
||||
publishDir,
|
||||
sessionId: sid,
|
||||
syncWorkspaceAssets:
|
||||
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
||||
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
||||
|
||||
Reference in New Issue
Block a user