WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
#!/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.')
|
||||
}
|
||||
Reference in New Issue
Block a user