#!/usr/bin/env node 'use strict' const assert = require('node:assert/strict') const http = require('node:http') const crypto = require('node:crypto') const { readFileSync, writeFileSync, statSync } = require('node:fs') const path = require('node:path') // Requires two disposable users and an already-enabled disposable workspace. // It never changes workspace switches, registers users, or resets a database. const base = (process.env.MEMORY_SMOKE_BASE || 'http://127.0.0.1:18080/api/v1').replace(/\/$/, '') const run = process.env.MEMORY_SMOKE_RUN || `memory-smoke-${crypto.randomBytes(8).toString('hex')}` assert.match(run, /^memory-smoke-[a-f0-9]{16}$/) const manifestPath = path.resolve(process.env.MEMORY_SMOKE_MANIFEST || `${run}.json`) const manifest = { run, base, started_at: new Date().toISOString(), resources: [], jobs: [], memories: [], candidates: [], checks: [], cleanup_errors: [] } const captured = [] let owner, outsider, authorID, server, task, flow, model, author const persist = () => writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }) const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) const sqlString = value => `'${String(value).replaceAll("'", "''")}'` function credentials(file) { assert.ok(file, 'Set MEMORY_SMOKE_OWNER_AUTH and MEMORY_SMOKE_OUTSIDER_AUTH to local JSON auth files') assert.equal(statSync(file).mode & 0o077, 0, 'Auth file must not be readable by other users') const value = JSON.parse(readFileSync(file, 'utf8')) assert.equal(typeof value.token, 'string', 'Auth file requires token') return value } async function request(route, body, method = body === undefined ? 'GET' : 'POST', statuses = [200], identity = owner) { const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${identity.token}` } if (identity.tenantID) headers['X-Tenant-ID'] = String(identity.tenantID) const response = await fetch(base + route, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) }) const text = await response.text() if (response.status === 204 && statuses.includes(204)) return {} let value try { value = JSON.parse(text) } catch { throw new Error(`${method} ${route}: non-JSON HTTP ${response.status}`) } assert.ok(statuses.includes(response.status), `${method} ${route}: HTTP ${response.status}; ${JSON.stringify(value.error || value.message || '').slice(0, 300)}`) return value } async function listCandidates() { const response = await request('/workbench/operations/records/memories?page_size=100') const rows = (response.data || []).filter(row => task && row.source_ref.startsWith(`内容任务/${task.id}/`)) for (const row of rows) if (!manifest.candidates.includes(row.id)) manifest.candidates.push(row.id) persist() return rows } async function listMemories() { const response = await request('/memory/items?limit=200') for (const row of response.data || []) { if (row.content.includes(run) && !manifest.memories.includes(row.id)) manifest.memories.push(row.id) } persist() return response.data || [] } async function reconfirmLegacyFixture() { if (process.env.MEMORY_SMOKE_LEGACY !== '1') return null assert.equal(typeof process.send, 'function', 'Legacy fixture requires the isolated workspace wrapper') const seeded = await new Promise((resolve, reject) => { const timer = setTimeout(() => { process.off('message', onMessage); reject(new Error('Legacy fixture seed timed out')) }, 15000) function onMessage(message) { if (message?.type !== 'legacy-memory-seeded') return clearTimeout(timer) process.off('message', onMessage) if (message.error) reject(new Error(message.error)) else resolve(message) } process.on('message', onMessage) process.send({ type: 'seed-legacy-memory', run, agent_id: authorID }) }) assert.match(seeded.id, /^[a-f0-9-]{36}$/) manifest.candidates.push(seeded.id) manifest.legacy_history_id = seeded.history_id persist() const find = async () => (await request('/workbench/operations/records/memories?page_size=100')).data.find(row => row.id === seeded.id) const before = await find() assert.equal(before.status, 'accepted') assert.equal(before.legacy_history_id, seeded.history_id) assert.equal(before.can_reconfirm, true) assert.ok(!before.native_memory_id) const content = `${run}:解释方案时先给判断标准,再给两条示例。` const body = { status: 'accepted', content, reconfirm_legacy: true, confirm_personal_scope: true } const route = `/workbench/operations/memories/${seeded.id}` await request(route, body, 'POST', [403, 404], outsider) await request(route, { ...body, confirm_personal_scope: false }, 'POST', [400]) await request(route, body) await request(route, body) const after = await find() assert.equal(after.content, before.content, 'Legacy original text was overwritten') assert.equal(after.source_ref, before.source_ref, 'Legacy provenance was overwritten') assert.equal(after.reconfirmed_content, content) assert.equal(after.can_reconfirm, false) assert.ok(after.reconfirmed_at && after.native_memory_id) const linked = (await listMemories()).filter(row => row.source_message_id === seeded.id) assert.equal(linked.length, 1, 'Legacy reconfirmation was not idempotent') assert.equal(linked[0].id, after.native_memory_id) assert.equal(linked[0].content, content) assert.equal(linked[0].subject_id, manifest.subject_id) assert.equal(linked[0].status, 'active') await request(`/memory/items/${linked[0].id}`, undefined, 'DELETE', [404], outsider) assert.ok(!(await request('/memory/items?limit=200', undefined, 'GET', [200], outsider)).data.some(row => row.id === linked[0].id)) manifest.checks.push('Legacy owner reconfirmation requires consent, preserves provenance, stores one native preference and rejects foreign access') persist() return { id: linked[0].id, candidate_id: seeded.id, route, body, content, find } } async function finishLegacyFixture(legacy) { if (!legacy) return assert.ok(captured.at(-1).system.includes(legacy.content), 'Reconfirmed legacy preference was missing from actual generation prompt') await request(`/memory/items/${legacy.id}`, undefined, 'DELETE', [200, 204]) await request(legacy.route, legacy.body) assert.equal((await legacy.find()).native_memory_id, legacy.id, 'Legacy link was removed after native deletion') assert.ok(!(await listMemories()).some(row => row.id === legacy.id || row.source_message_id === legacy.candidate_id), 'Repeated confirmation resurrected deleted native memory') manifest.checks.push('Reconfirmed legacy preference reaches actual generation prompt; deliberate native deletion remains deleted after repeat confirmation') persist() } async function act(action, extra = {}, expectedStatus = 'succeeded') { const job = (await request(`/workbench/resources/content-tasks/${task.id}/actions/${action}`, { version: task.version, ...extra }, 'POST', [202])).data manifest.jobs.push(job.id) persist() const deadline = Date.now() + 90000 while (Date.now() < deadline) { const current = (await request(`/workbench/jobs/${job.id}`)).data if (!['queued', 'running'].includes(current.status)) { assert.equal(current.status, expectedStatus, `${action}: ${current.error || current.status}`) task = (await request(`/workbench/resources/content-tasks/${task.id}`)).data return current } await pause(350) } throw new Error(`Job timeout: ${job.id}; cleanup must wait until the job stops`) } function startModel() { server = http.createServer(async (req, res) => { try { let text = '' for await (const chunk of req) { text += chunk assert.ok(text.length < 2 * 1024 * 1024, 'Model request exceeds fixture limit') } const body = JSON.parse(text) const messages = body.messages || [] const system = messages.filter(message => message.role === 'system').map(message => message.content).join('\n') const prompt = String(messages.at(-1)?.content || '') captured.push({ system, prompt }) let content = `测试正文 ${run}:先说明判断依据,再讨论应用步骤。` if (system.includes('内容编排师')) content = JSON.stringify({ segments: [1, 2, 3].map(n => ({ id: `s${n}`, title: `验证步骤${n}`, node_id: 'writer', agent_id: authorID })) }) if (system.includes('内置跨稿质检员')) content = JSON.stringify({ passed: true, summary: '本地固定质检通过', issues: [] }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ id: run, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: run, choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } })) } catch { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: { message: 'Invalid smoke-model request' } })) } }) return new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '0.0.0.0', () => resolve(server.address().port)) }) } async function preflight() { const url = new URL(base) assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Use the loopback origin; public URLs are refused') owner = credentials(process.env.MEMORY_SMOKE_OWNER_AUTH) outsider = credentials(process.env.MEMORY_SMOKE_OUTSIDER_AUTH) const me = (await request('/auth/me')).data const other = (await request('/auth/me', undefined, 'GET', [200], outsider)).data assert.ok(me.user.username.startsWith('memory-smoke-'), 'Owner must be a dedicated memory-smoke- user') assert.ok(me.tenant.name.startsWith('memory-smoke-'), 'Workspace must be a dedicated memory-smoke- workspace') assert.ok(other.user.username.startsWith('memory-smoke-'), 'Outsider must be a dedicated memory-smoke- user') assert.notEqual(me.user.id, other.user.id) manifest.tenant_id = me.tenant.id manifest.owner_id = me.user.id manifest.subject_id = `web_user:${me.user.id}` const settings = (await request('/memory/settings')).data assert.equal(settings.effective, true, 'Enable memory on the disposable workspace/user before running') assert.equal((await request('/memory/items?limit=1')).total, 0, 'Owner already has memory; refuse to alter it') assert.equal((await request('/workbench/operations/records/memories?page_size=1')).total, 0, 'Owner already has review history; use another fixture user') for (const kind of ['flows', 'content-tasks', 'training-maps']) { const rows = (await request(`/workbench/resources/${kind}`)).data || [] assert.equal(rows.length, 0, `Fixture workspace already has ${kind}`) } persist() } async function main() { await preflight() const port = await startModel() const modelHost = process.env.MEMORY_SMOKE_MODEL_HOST || 'host.docker.internal' assert.ok(['host.docker.internal', '127.0.0.1', 'localhost'].includes(modelHost), 'Fake-model host must resolve to this machine') model = (await request('/models', { name: run, type: 'KnowledgeQA', source: 'remote', parameters: { base_url: `http://${modelHost}:${port}/v1`, api_key: 'local-smoke-only', provider: 'generic' } }, 'POST', [201])).data manifest.model_id = model.id persist() author = (await request('/agents', { name: run, config: { model_id: model.id, system_prompt: '测试内容作者', role: 'production', agent_mode: 'quick-answer', memory_enabled: true } }, 'POST', [201])).data authorID = author.id manifest.agent_id = authorID persist() flow = (await request('/workbench/resources/flows', { name: run, data: { nodes: [{ id: 'writer', agent_id: authorID, label: '测试作者' }], edges: [] } }, 'POST', [201])).data manifest.resources.push({ id: flow.id, kind: 'flows' }) persist() task = (await request('/workbench/resources/content-tasks', { name: run, data: { day: 1, flow_id: flow.id, agent_id: authorID, source_text: '判断依据、实际应用、总结回顾', knowledge_base_ids: [], duration_minutes: 135 } }, 'POST', [201])).data manifest.resources.push({ id: task.id, kind: 'content-tasks' }) persist() assert.equal(task.data.duration_minutes, 135, 'Create lost duration') task = (await request(`/workbench/resources/content-tasks/${task.id}`, { name: task.name, version: task.version, data: { ...task.data, duration_minutes: 90 } }, 'PUT')).data assert.equal(task.data.duration_minutes, 90, 'Update lost duration') for (const invalid of [-1, 0.5, 1441]) { await request(`/workbench/resources/content-tasks/${task.id}`, { name: task.name, version: task.version, data: { ...task.data, duration_minutes: invalid } }, 'PUT', [400]) assert.equal((await request(`/workbench/resources/content-tasks/${task.id}`)).data.data.duration_minutes, 90, 'Invalid duration changed persisted state') } manifest.checks.push('Duration persists 135-minute create and 90-minute update; negative/fractional/over-1440 updates are rejected') await act('decompose') assert.equal(task.data.segments.length, 3) const advice = `${run}:回答先列要点,再给例子。` await act('generate', { segment_id: 's1', advice }) assert.equal((await listCandidates()).length, 0, 'Unaccepted draft created feedback memory') assert.equal((await listMemories()).length, 0, 'AI draft became a user memory') await act('accept', { segment_id: 's1' }) let rows = await listCandidates() assert.equal(rows.length, 1) const candidate = rows[0] assert.equal(candidate.content, advice) assert.equal(candidate.status, 'pending') await act('accept', { segment_id: 's1' }) assert.equal((await listCandidates()).length, 1, 'Repeated adoption duplicated feedback') await request(`/workbench/operations/memories/${candidate.id}`, { status: 'accepted' }, 'POST', [403, 404], outsider) await request(`/workbench/operations/memories/${candidate.id}`, { status: 'accepted' }) const memories = await listMemories() assert.equal(memories.length, 1) assert.equal(memories[0].content, advice) assert.equal(memories[0].status, 'active') manifest.subject_id = memories[0].subject_id await request(`/memory/items/${memories[0].id}`, undefined, 'DELETE', [404], outsider) const otherRows = (await request('/memory/items?limit=200', undefined, 'GET', [200], outsider)).data || [] assert.ok(!otherRows.some(row => row.id === memories[0].id)) manifest.checks.push('Draft is excluded; accepted human advice is durable, idempotent and owner isolated; review writes active memory') const legacy = await reconfirmLegacyFixture() await act('generate', { segment_id: 's2', advice: `${run}:第二段使用简短段落。` }) assert.ok(captured.at(-1).system.includes(advice), 'Approved memory did not reach the actual fake-model prompt') await finishLegacyFixture(legacy) await act('accept', { segment_id: 's2' }) const skipped = (await listCandidates()).find(row => row.source_ref.endsWith('/内容段/s2')) assert.equal(skipped.status, 'pending') await act('skip', { segment_id: 's2' }) assert.equal((await listCandidates()).find(row => row.id === skipped.id).status, 'invalidated') await request(`/workbench/operations/memories/${skipped.id}`, { status: 'accepted' }, 'POST', [409]) await act('generate', { segment_id: 's3', advice: `${run}:总结保留清晰步骤。` }) await act('accept', { segment_id: 's3' }) const deleted = (await listCandidates()).find(row => row.source_ref.endsWith('/内容段/s3')) const originalProse = task.data.segments.map(row => row.content) await act('review') assert.equal(task.data.quality.duration_minutes, 90) await act('compose') assert.equal(task.data.composed, task.data.segments.filter(row => row.status === 'accepted').map(row => row.content).join('\n\n')) const callsBeforeDurationChange = captured.length task = (await request(`/workbench/resources/content-tasks/${task.id}`, { name: task.name, version: task.version, data: { ...task.data, duration_minutes: 225 } }, 'PUT')).data task = (await request(`/workbench/resources/content-tasks/${task.id}`)).data assert.equal(task.data.duration_minutes, 225) assert.deepEqual(task.data.segments.map(row => row.content), originalProse, 'Duration change lost saved prose') assert.deepEqual(task.data.segments.map(row => row.status), ['needs_review', 'skipped', 'needs_review']) assert.ok(!task.data.quality && !task.data.composed && !task.data.review, 'Duration change reused old quality approval') const blockedCompose = await act('compose', {}, 'failed') assert.ok(blockedCompose.error.includes('重新确认'), 'Composition failed for an unexpected reason') assert.equal(captured.length, callsBeforeDurationChange, 'Duration update or rejected composition invoked a model') assert.equal((await listCandidates()).find(row => row.id === deleted.id).status, 'invalidated') await act('accept', { segment_id: 's1' }) await act('accept', { segment_id: 's3' }) assert.equal((await listCandidates()).find(row => row.id === deleted.id).status, 'pending', 'Reacceptance did not restore pending source') assert.equal((await listCandidates()).find(row => row.id === candidate.id).status, 'accepted', 'Reacceptance reset an approved memory') manifest.checks.push('Changing a composed task duration preserves prose, invalidates quality and pending sources, blocks stale composition, and reacceptance restores only pending feedback') await request(`/workbench/resources/content-tasks/${task.id}`, undefined, 'DELETE') assert.equal((await listCandidates()).find(row => row.id === deleted.id).status, 'invalidated') assert.equal((await listMemories()).length, 1, 'Source deletion altered separately approved memory') manifest.checks.push('Approved preference is recalled; skip/deletion invalidate pending feedback without undoing explicit approval') manifest.model_calls = captured.length manifest.passed = true persist() } async function cleanup() { if (!owner || !manifest.owner_id) return // Never delete while a worker can still write to the fixture resources. for (const id of manifest.jobs) { try { const row = (await request(`/workbench/jobs/${id}`)).data if (['queued', 'running'].includes(row.status)) { manifest.cleanup_errors.push(`Job ${id} is still ${row.status}; fixture retained for bounded cleanup`) persist() return } } catch (error) { manifest.cleanup_errors.push(error.message); persist(); return } } try { await listMemories(); await listCandidates() } catch (error) { manifest.cleanup_errors.push(error.message) } const remove = async route => { try { await request(route, undefined, 'DELETE', [200, 204, 404]) } catch (error) { manifest.cleanup_errors.push(error.message) } } for (const id of manifest.memories) await remove(`/memory/items/${id}`) for (const resource of [...manifest.resources].reverse()) await remove(`/workbench/resources/${resource.kind}/${resource.id}`) if (author) await remove(`/agents/${author.id}`) if (model) await remove(`/models/${model.id}`) const tenant = Number(manifest.tenant_id) assert.ok(Number.isSafeInteger(tenant) && tenant > 0) const ownerID = sqlString(manifest.owner_id) const resourceIDs = manifest.resources.map(row => sqlString(row.id)).join(',') || "''" const candidateIDs = manifest.candidates.map(sqlString).join(',') || "''" const subject = sqlString(manifest.subject_id) // API deletion intentionally retains audit/history and rejection tombstones. // This optional exact-ID SQL removes only this run's workbench history. const cleanupSQL = `BEGIN;\nSET LOCAL lock_timeout = '3s';\nDO $$ BEGIN\n IF EXISTS (SELECT 1 FROM workbench_jobs WHERE tenant_id=${tenant} AND owner_id=${ownerID} AND resource_id IN (${resourceIDs}) AND status IN ('queued','running')) THEN RAISE EXCEPTION 'Fixture jobs still active'; END IF;\nEND $$;\nDELETE FROM workbench_pending_memories WHERE tenant_id=${tenant} AND owner_id=${ownerID} AND id IN (${candidateIDs});\nDELETE FROM workbench_revisions WHERE tenant_id=${tenant} AND resource_id IN (${resourceIDs});\nDELETE FROM workbench_events WHERE tenant_id=${tenant} AND owner_id=${ownerID} AND resource_id IN (${resourceIDs});\nDELETE FROM workbench_jobs WHERE tenant_id=${tenant} AND owner_id=${ownerID} AND resource_id IN (${resourceIDs});\nDELETE FROM memory_tombstones WHERE tenant_id=${tenant} AND subject_id=${subject} AND source_message_id IN (${candidateIDs});\nCOMMIT;\n` writeFileSync(`${manifestPath}.cleanup.sql`, cleanupSQL, { mode: 0o600 }) manifest.finished_at = new Date().toISOString() manifest.cleanup_note = 'Created resources and native memories are API-deleted. Exact-ID cleanup SQL is supplied for review history/tombstones; audit/usage logs and fixture identities remain for verification. No original user data was deleted.' persist() } if (process.argv.includes('--run')) { main().catch(error => { manifest.error = error.message; manifest.passed = false; process.exitCode = 1 }).finally(async () => { try { await cleanup() } catch (error) { manifest.cleanup_errors.push(error.message); process.exitCode = 1; persist() } server?.close() if (process.connected) process.disconnect() if (manifest.cleanup_errors.length) process.exitCode = 1 console.log(JSON.stringify({ passed: manifest.passed || false, checks: manifest.checks, error: manifest.error, cleanup_errors: manifest.cleanup_errors, manifest: manifestPath }, null, 2)) }) } else { console.log('Set MEMORY_SMOKE_OWNER_AUTH and MEMORY_SMOKE_OUTSIDER_AUTH (token JSON files), optionally MEMORY_SMOKE_BASE/MEMORY_SMOKE_MODEL_HOST/MEMORY_SMOKE_MANIFEST, then run with --run. Requires a disposable memory-smoke- workspace and two memory-smoke- users; never registers or resets anything. See scripts/native_memory_api_smoke.md.') }