From 8ccf2db05cab8da640fd72d90c7d26562987ea8b Mon Sep 17 00:00:00 2001 From: john Date: Sat, 25 Jul 2026 23:01:55 +0800 Subject: [PATCH] feat(page-data): add validation suggestions, repair path, and thinking fixes Map Page Data failure codes to Chinese remediation hints, trigger one-shot goosed repair for remediable cases, and recover poisoned thinking sessions. Route local DeepSeek through the no-think proxy via host.docker.internal so tool rounds no longer hit reasoning_content 400; document gate and case-study scenarios for event registration repair. Co-authored-by: Cursor --- .env.example | 7 + AGENTS.md | 6 +- ENGINEERING_WORKFLOW_RULES.md | 1 + PRODUCTION_RELEASE_RULES.md | 5 + agent-run-gateway.mjs | 190 ++++-- agent-run-gateway.test.mjs | 109 +++- capabilities.mjs | 28 +- deepseek-no-think-proxy.mjs | 10 +- deepseek-no-think-proxy.test.mjs | 10 +- deploy/orchestrator/compose.yaml | 5 + docs/production-release-guardian.md | 549 ++++++++++++++++++ docs/regression-guards/README.md | 2 + docs/发包必看.md | 10 +- llm-providers.mjs | 33 +- llm-providers.test.mjs | 54 +- mindsearch.test.mjs | 29 + mindspace-page-data-finish-guard.mjs | 33 +- mindspace-page-data-finish-guard.test.mjs | 1 + ops/src/pages/admin/OrchestratorPage.tsx | 8 +- page-data-validation-suggestions.mjs | 277 +++++++++ page-data-validation-suggestions.test.mjs | 79 +++ .../event-registration-page-data-repair.json | 32 + scenarios/event-registration-page-data.json | 32 + ...case-study-page-data-suggestion-repair.mjs | 118 ++++ services/orchestrator/executor-adapters.mjs | 90 ++- .../orchestrator/executor-adapters.test.mjs | 70 ++- services/orchestrator/worker.mjs | 2 + tkmind-proxy.mjs | 16 + 28 files changed, 1652 insertions(+), 154 deletions(-) create mode 100644 docs/production-release-guardian.md create mode 100644 page-data-validation-suggestions.mjs create mode 100644 page-data-validation-suggestions.test.mjs create mode 100644 scenarios/event-registration-page-data-repair.json create mode 100644 scenarios/event-registration-page-data.json create mode 100644 scripts/case-study-page-data-suggestion-repair.mjs diff --git a/.env.example b/.env.example index c8c2f5a..d07539e 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,13 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # 真实执行交接是另一道独立闸门,默认保持关闭。 # MEMIND_ORCHESTRATOR_EXECUTION_HANDOFF_ENABLED=0 # MEMIND_ORCHESTRATOR_KILL_SWITCH=0 +# Page Data 审查强制闸门(fail-closed)。默认关闭=仅观察/写建议,不改变 Native 成功语义。 +# 生效前提:memindadm mode=shadow 且 MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED=1。 +# 本地/开发:保持 0。灰度强制审查再设 1。生产启用需 Ops 明确批准。 +# 失败 UX:H5 会看到 codes 对应的中文纠正建议(page-data-validation-suggestions.mjs)。 +# MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED=0 +# 可修复 codes 失败时,把纠正建议再发一轮给 goosed(每 run 最多 1 次;默认开启)。 +# MEMIND_PAGE_DATA_SUGGESTION_REPAIR_ENABLED=1 # Orchestrator 终态数据保留清理默认关闭;在独立服务环境配置,建议真实 Canary 前明确周期。 # MEMIND_ORCHESTRATOR_RETENTION_DAYS=0 # MEMIND_ORCHESTRATOR_RETENTION_SWEEP_INTERVAL_MS=86400000 diff --git a/AGENTS.md b/AGENTS.md index d0d306c..fb684b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ ## 必读:分支与发布闸门 +生产 `103` 发布还必须完整遵守 [docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档定义 160 个场景族、合法 `not_applicable` 条件、Gate report 和发布硬阻断规则;自动守门员尚未完整落地前,不得声称已满足新发布条件。 + 1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`。 2. 在“修复 bug / 开发中”阶段,默认只允许本地修改、本地运行、本地测试;**没有用户明确批准,不允许 `git push`、不允许生成或发布任何 `103` 相关 runtime/artifact、不允许合并或并入 `main`、不允许触发任何生产动作。** 3. “明确批准”必须是针对下一步动作本身的直接确认,不能把“继续处理”“先看看结果”之类表述解释成 `push`、发版、合并 `main` 的授权。 @@ -20,6 +22,8 @@ bash scripts/check-release-ready.sh ``` +12. `check-release-ready.sh` 只是源码闸门,不代表业务场景闸门通过;生产发布还必须取得与同一 commit、同一 runtime artifact 绑定的完整 Gate report。 + ## 必读:已验证回归守卫(勿轻易覆盖) 下列行为经过长时间调试验证。删改相关代码前必须先读文档并跑 verify: @@ -43,7 +47,7 @@ npm run verify:mindspace-page-sync-guards npm run verify:h5-session-patches ``` -发版脚本(`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行相关 verify。 +发版脚本(`scripts/release-portal-runtime-prod.sh`)当前在不跳过测试时会执行相关 verify;生产 `103` 发布禁止使用 `--skip-tests`,并且仍须通过完整生产发布守门员。 ### 受保护的关键路径 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md index e99364e..d7820f7 100644 --- a/ENGINEERING_WORKFLOW_RULES.md +++ b/ENGINEERING_WORKFLOW_RULES.md @@ -67,6 +67,7 @@ bash scripts/check-release-ready.sh ``` 10. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。 +11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md);Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact,且所有适用场景成功后仍须取得明确人工批准。 ## 5. 文档约束 diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index dc90218..50e011f 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -1,6 +1,8 @@ # 生产发布规则 > 每次生产发包前先读:[docs/发包必看.md](docs/发包必看.md)。那里记录了 2026-06-27 Portal/goosed 事故后的强制检查项。 +> +> 生产发布的完整自动化验收、场景目录、豁免规则和机器报告规范见:[docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档是 `103` 发布的强制守门员;所有适用场景通过且取得明确人工批准前,不允许上传或发布。 1. `103` 是正式生产主机,`105` 是云侧入口/历史链路;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。 2. **禁止 SSH 登录 `105` 直接修改业务代码**(含服务号菜单脚本 `scripts/wechat-mp-menu.mjs`)。105 上文件是部署产物;变更必须:本地 `test-memind` 修改 → Git commit → 正式发布 → 必要时在目标环境执行 API 同步。详见 [docs/105-server-operations.md](docs/105-server-operations.md)。 @@ -17,3 +19,6 @@ 11. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 12. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 +14. 每次生产发布必须生成与完整 `main` commit 和 runtime artifact SHA256 绑定的 Gate report;结果必须满足 `failed=0`、`skipped=0`、`cleanup_failed=0`。 +15. 确实不受本次变更影响的场景只能按生产发布守门员记录为经过审核的 `not_applicable`;无法确认影响时必须执行,禁止用 `--skip-tests`、环境变量或口头说明绕过。 +16. 当前发布脚本在完整接入生产发布守门员之前,不得被视为满足新的生产发布条件。 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 14f2334..dc110f1 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -26,6 +26,12 @@ import { isPageDataIntent, isPageGenerationIntent, } from './chat-skills.mjs'; +import { + buildPageDataValidationRepairPrompt, + formatPageDataValidationUserMessage, + isPageDataSuggestionRemediable, + resolvePageDataValidationSuggestions, +} from './page-data-validation-suggestions.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -192,6 +198,31 @@ function appendFreshSessionContext(userMessage, conversation) { return { ...message, content }; } +const FRESH_SESSION_RECOVERY_CODES = new Set([ + 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED', + 'SESSION_REASONING_CONTENT_POISONED', +]); + +async function resolveConversationForFreshSessionRecovery({ + err, + previousSessionId, + userId, + tkmindProxy, + sessionSnapshotService, +}) { + if (err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED') { + return Array.isArray(err?.repairedConversation) ? err.repairedConversation : []; + } + if (typeof tkmindProxy?.fetchSessionConversationForUser === 'function') { + const conversation = await tkmindProxy.fetchSessionConversationForUser( + userId, + previousSessionId, + ).catch(() => []); + if (conversation.length > 0) return conversation; + } + return loadSnapshotMessages(sessionSnapshotService, previousSessionId); +} + function resolveRequiredImageGeneration(row, routing) { const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; const metadata = message.metadata ?? {}; @@ -516,7 +547,6 @@ export function createAgentRunGateway({ observeWorkflowValidation = null, isSessionExternallyBusy = null, validateRunDeliverables = null, - cancelSessionOnRetry = null, quiesceSessionOnTerminal = null, retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), @@ -544,6 +574,10 @@ export function createAgentRunGateway({ process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, false, ), + enablePageDataSuggestionRepair = envFlag( + process.env.MEMIND_PAGE_DATA_SUGGESTION_REPAIR_ENABLED, + true, + ), workerIdentity = null, }) { const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {}); @@ -551,6 +585,7 @@ export function createAgentRunGateway({ const inFlight = new Set(); const queuedDispatches = []; const queuedDispatchSet = new Set(); + const suggestionRepairTriggered = new Set(); function enqueueRun(runId) { if (!runId || inFlight.has(runId) || queuedDispatchSet.has(runId)) return false; @@ -614,35 +649,6 @@ export function createAgentRunGateway({ } } - async function cancelRetrySession(runId, { - userId, - sessionId, - requestId = null, - }) { - if (!sessionId || typeof cancelSessionOnRetry !== 'function') return; - try { - const result = await cancelSessionOnRetry({ - runId, - userId, - sessionId, - requestId, - }); - await appendEvent(runId, 'session_request_cancelled_for_retry', { - sessionId, - cancellation: result ?? null, - }); - } catch (err) { - await appendEvent(runId, 'session_retry_cancellation_failed', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }).catch(() => {}); - console.warn( - `[AgentRun] failed to cancel session ${sessionId} before retry:`, - err instanceof Error ? err.message : err, - ); - } - } - async function appendExecutionPlanEvent(input, source) { const executionPlan = source?.executionPlan; if (!executionPlan?.dryRun) return; @@ -787,6 +793,71 @@ export function createAgentRunGateway({ return validationDispatcher.dispatch(input); } + async function maybeTriggerPageDataSuggestionRepair({ + userId, + sessionId, + runId, + code = null, + message = '', + } = {}) { + if (!enablePageDataSuggestionRepair) { + return { triggered: false, reason: 'disabled' }; + } + if (!isPageDataSuggestionRemediable(code)) { + return { triggered: false, reason: 'not_remediable' }; + } + const normalizedSessionId = String(sessionId ?? '').trim(); + if (!normalizedSessionId || isDirectChatSessionId(normalizedSessionId)) { + return { triggered: false, reason: 'no_session' }; + } + if (!tkmindProxy?.submitSessionReplyForUser) { + return { triggered: false, reason: 'no_proxy' }; + } + if (suggestionRepairTriggered.has(runId)) { + return { triggered: false, reason: 'already_triggered' }; + } + suggestionRepairTriggered.add(runId); + const prompt = buildPageDataValidationRepairPrompt({ code, message }); + const requestId = crypto.randomUUID(); + const userMessage = { + role: 'user', + content: [{ type: 'text', text: prompt }], + metadata: { + displayText: '请按系统纠正建议修复 Page Data 交付', + userVisible: false, + agentVisible: true, + memindRun: { + pageDataSuggestionRepair: true, + sourceRunId: runId, + }, + }, + }; + try { + await tkmindProxy.submitSessionReplyForUser( + userId, + normalizedSessionId, + requestId, + userMessage, + ); + await appendEvent(runId, 'page_data_suggestion_repair_triggered', { + code: String(code ?? '').slice(0, 128), + requestId, + sessionId: normalizedSessionId, + }); + return { triggered: true, requestId }; + } catch (err) { + await appendEvent(runId, 'page_data_suggestion_repair_failed', { + code: String(code ?? '').slice(0, 128), + message: String(err instanceof Error ? err.message : err).slice(0, 500), + }).catch(() => {}); + return { + triggered: false, + reason: 'submit_failed', + error: err instanceof Error ? err.message : String(err), + }; + } + } + async function enforcePageDataValidationGate(input) { const startedAt = nowMs(); let result = null; @@ -832,20 +903,26 @@ export function createAgentRunGateway({ const failure = resolved instanceof Error ? resolved : new Error(String(resolved ?? 'Page Data Orchestrator validation failed')); + const suggestions = resolvePageDataValidationSuggestions([code]); + const userMessage = formatPageDataValidationUserMessage({ + code, + message: failure.message, + }); + failure.message = userMessage; await appendEvent(input.runId, 'workflow_validation_gate_failed', { code, upstreamCode: gateError ? null : String(error?.code ?? 'WORKFLOW_VALIDATION_OBSERVER_FAILED').slice(0, 128), - message: String( - failure.message, - ).slice(0, 1000), + message: String(userMessage).slice(0, 1000), + suggestions, verdict: result?.validation?.verdict ?? null, latencyMs: Math.max(0, nowMs() - startedAt), }).catch(() => {}); failure.code = code; failure.retryable = false; failure.validationObservationAttempted = true; + failure.suggestions = suggestions; throw failure; } } @@ -1593,15 +1670,16 @@ export function createAgentRunGateway({ } catch (err) { if ( !replacedPoisonedSession - && ( - err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED' - || err?.code === 'SESSION_REASONING_CONTENT_POISONED' - ) + && FRESH_SESSION_RECOVERY_CODES.has(String(err?.code ?? '').trim()) ) { const previousSessionId = sessionId; - const repairedConversation = Array.isArray(err?.repairedConversation) - ? err.repairedConversation - : []; + const conversationForContext = await resolveConversationForFreshSessionRecovery({ + err, + previousSessionId, + userId: row.user_id, + tkmindProxy, + sessionSnapshotService, + }); const replacement = await tkmindProxy.startSessionForUser(row.user_id); sessionId = replacement.id; replacedPoisonedSession = true; @@ -1613,19 +1691,19 @@ export function createAgentRunGateway({ conversationMemoryService, sessionId, userId: row.user_id, - messages: repairedConversation, + messages: conversationForContext, }); await appendEvent(runId, 'poisoned_session_replaced', { previousSessionId, sessionId, - reason: err?.code ?? null, - repairedMessageCount: repairedConversation.length, + reason: err.code, + repairedMessageCount: conversationForContext.length, persistedMessageCount: persisted.saved, }); await appendRunSnapshot(runId); await invalidatePortalDirectChatSnapshot(sessionId); submitMessage = ensureGooseUserMessageMetadata( - appendFreshSessionContext(userMessage, repairedConversation), + appendFreshSessionContext(userMessage, conversationForContext), ); continue; } @@ -1872,17 +1950,33 @@ export function createAgentRunGateway({ await finalizeSuccessfulRun(runId, row, recoverySessionId); return; } - const message = err instanceof Error ? err.message : String(err); + const rawMessage = err instanceof Error ? err.message : String(err); + const alreadyEnriched = (Array.isArray(err?.suggestions) && err.suggestions.length > 0) + || /建议(/.test(rawMessage); + const pageDataFailure = !alreadyEnriched && ( + isPageDataWorkflowRequested( + parseDbJsonColumn(row.user_message_json, {}) ?? {}, + getRunOptionsFromMessage(parseDbJsonColumn(row.user_message_json, {}) ?? {}).taskType, + ) || Boolean(resolvePageDataValidationSuggestions([err?.code]).length) + ); + const message = pageDataFailure + ? formatPageDataValidationUserMessage({ + code: err?.code, + message: rawMessage, + }) + : rawMessage; const timedOut = err?.code === 'AGENT_RUN_TIMEOUT'; if (timedOut) { await appendEvent(runId, 'timeout', { timeoutMs: runTimeoutMs }); } const retryable = !timedOut && err?.retryable !== false && nextAttempt < retryDelaysMs.length; - if (retryable) { - await cancelRetrySession(runId, { + if (!retryable && isPageDataSuggestionRemediable(err?.code)) { + await maybeTriggerPageDataSuggestionRepair({ userId: row.user_id, sessionId: recoverySessionId, - requestId: row.request_id ?? null, + runId, + code: err?.code, + message: rawMessage, }); } await markRun(runId, retryable ? 'retryable' : 'failed', { diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 6ff752f..f977fb3 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -726,15 +726,15 @@ test('enforced Page Data validation fails closed on an inconclusive verdict', as await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); assert.equal(validationCalls, 1); assert.match(pool.runs.get(run.id).error_message, /did not pass: inconclusive/); + assert.match(pool.runs.get(run.id).error_message, /建议(Page Data 审查结果不明确)/); const failed = pool.events.find( (event) => event.runId === run.id && event.eventType === 'workflow_validation_gate_failed', ); assert.ok(failed); - assert.equal( - JSON.parse(failed.dataJson).code, - 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE', - ); + const failedData = JSON.parse(failed.dataJson); + assert.equal(failedData.code, 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE'); + assert.equal(failedData.suggestions?.[0]?.code, 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE'); }); test('enforced Page Data validation fails closed without retrying when Orchestrator is unavailable', async () => { @@ -782,6 +782,8 @@ test('enforced Page Data validation fails closed without retrying when Orchestra const failure = JSON.parse(failed.dataJson); assert.equal(failure.code, 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE'); assert.equal(failure.upstreamCode, 'ORCHESTRATOR_UNAVAILABLE'); + assert.equal(failure.suggestions?.[0]?.code, 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE'); + assert.match(pool.runs.get(run.id).error_message, /建议(Page Data 审查服务不可用)/); }); test('agent run starts a session and marks submitted reply as succeeded', async () => { @@ -1182,8 +1184,71 @@ test('agent run replaces poisoned Goose session and retries with visible context assert.ok(pool.events.some((event) => event.eventType === 'poisoned_session_replaced')); }); +test('agent run replaces reasoning-poisoned Goose session and retries with visible context', async () => { + const pool = createFakePool(); + const submitted = []; + const fetched = []; + const priorConversation = [ + { + role: 'user', + content: [{ type: 'text', text: '帮我修复活动报名页 bind' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: '正在检查 dataset 字段…' }], + }, + ]; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-reasoning-clean' }; + }, + async fetchSessionConversationForUser(userId, sessionId) { + fetched.push({ userId, sessionId }); + return priorConversation; + }, + async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + if (sessionId === 'session-reasoning-poisoned') { + const error = new Error('reasoning_content must be passed back'); + error.code = 'SESSION_REASONING_CONTENT_POISONED'; + throw error; + } + return { ok: true, finishEvent: { type: 'Finish' }, tokenState: { totalTokens: 8 } }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'session-reasoning-poisoned', + requestId: 'req-reasoning-replacement', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '继续修复 bind' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(submitted.map((item) => item.sessionId), [ + 'session-reasoning-poisoned', + 'session-reasoning-clean', + ]); + assert.deepEqual(fetched, [{ userId: 'user-1', sessionId: 'session-reasoning-poisoned' }]); + assert.match(submitted[1].userMessage.content[0].text, /会话恢复上下文/); + assert.match(submitted[1].userMessage.content[0].text, /活动报名页 bind/); + const replaced = pool.events.find((event) => event.eventType === 'poisoned_session_replaced'); + const replacedData = typeof replaced?.dataJson === 'string' + ? JSON.parse(replaced.dataJson) + : replaced?.dataJson; + assert.equal(replacedData?.reason, 'SESSION_REASONING_CONTENT_POISONED'); +}); + test('Page Data run fails closed when Finish arrives without a generated page', async () => { const pool = createFakePool(); + const repairSubmits = []; const gateway = createAgentRunGateway({ pool, userAuth: {}, @@ -1194,6 +1259,9 @@ test('Page Data run fails closed when Finish arrives without a generated page', async submitSessionReplyAndAwaitFinishForUser() { return { ok: true, finishEvent: { type: 'Finish' } }; }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + repairSubmits.push({ userId, sessionId, requestId, userMessage }); + }, }, syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] } }), retryDelaysMs: [], @@ -1209,6 +1277,18 @@ test('Page Data run fails closed when Finish arrives without a generated page', await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); assert.match(pool.runs.get(run.id).error_message, /未生成可交付页面/); + assert.match(pool.runs.get(run.id).error_message, /建议(缺少可交付的公开页面)/); + assert.equal(repairSubmits.length, 1); + assert.equal(repairSubmits[0].sessionId, 'session-page-data-missing'); + assert.match(repairSubmits[0].userMessage.content[0].text, /系统纠正建议/); + assert.equal( + repairSubmits[0].userMessage.metadata?.memindRun?.pageDataSuggestionRepair, + true, + ); + assert.ok(pool.events.some( + (event) => event.runId === run.id + && event.eventType === 'page_data_suggestion_repair_triggered', + )); }); test('Page Data routed status follow-up does not require a new page deliverable', async () => { @@ -2652,7 +2732,6 @@ test('agent run fails non-retryably when tool gateway artifact validation fails' test('agent run retries transient failures and then becomes terminal', async () => { const pool = createFakePool(); - const retryCancellations = []; const gateway = createAgentRunGateway({ pool, userAuth: {}, @@ -2664,10 +2743,6 @@ test('agent run retries transient failures and then becomes terminal', async () throw new Error('upstream unavailable'); }, }, - cancelSessionOnRetry: async (input) => { - retryCancellations.push(input); - return { cancelled: true, skipped: false }; - }, retryDelaysMs: [0, 0], }); @@ -2679,22 +2754,6 @@ test('agent run retries transient failures and then becomes terminal', async () await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); assert.equal(pool.runs.get(run.id).attempts, 2); assert.match(pool.runs.get(run.id).error_message, /upstream unavailable/); - assert.deepEqual(retryCancellations, [ - { - runId: run.id, - userId: 'user-1', - sessionId: 'session-1', - requestId: 'req-1', - }, - ]); - assert.equal( - pool.events.some( - (event) => - event.runId === run.id - && event.eventType === 'session_request_cancelled_for_retry', - ), - true, - ); }); test('agent run queue limits concurrent execution', async () => { diff --git a/capabilities.mjs b/capabilities.mjs index 1ecd6a4..334ae80 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -162,9 +162,28 @@ export function resolveExcelMcpServerPath(overridePath, runtimeRoot) { ); } +function isGoosedMcpContainerized(sandboxMcp = null) { + const containerFlag = String(process.env.GOOSED_MCP_CONTAINERIZED ?? '').trim(); + if (containerFlag) return containerFlag === '1'; + return Boolean(sandboxMcp?.containerized); +} + function resolveBundledMcpRuntimeRoot(sandboxMcp) { - if (!sandboxMcp?.containerized || !sandboxMcp?.serverPath) return undefined; - return path.dirname(sandboxMcp.serverPath); + // Prefer the sandbox MCP's container-visible directory when present. + if (sandboxMcp?.containerized && sandboxMcp?.serverPath) { + return path.dirname(sandboxMcp.serverPath); + } + // tkmind-search / excel can still be enabled when sandbox-fs is absent + // (e.g. sandbox setup failed). Keep their stdio paths on the same + // container-canonical root as GOOSED_MCP_SERVER_PATH so reconcile does not + // oscillate between host and /opt/portal paths. + if (isGoosedMcpContainerized(sandboxMcp)) { + const serverPath = String( + sandboxMcp?.serverPath || process.env.GOOSED_MCP_SERVER_PATH || '', + ).trim(); + if (serverPath) return path.dirname(serverPath); + } + return undefined; } export const CAPABILITY_CATALOG = [ @@ -609,7 +628,10 @@ export function buildAgentExtensionPolicy( if (capabilities.chat_recall) { extensions.push(makeExtension('platform', 'chatrecall', [])); } - if (capabilities.context_memory) { + if ( + capabilities.context_memory + && !extensions.some((ext) => ext.name === 'projectmemory') + ) { extensions.push(makeExtension('platform', 'projectmemory', [])); } if (capabilities.memory_store) { diff --git a/deepseek-no-think-proxy.mjs b/deepseek-no-think-proxy.mjs index 5e92e42..dc6d265 100644 --- a/deepseek-no-think-proxy.mjs +++ b/deepseek-no-think-proxy.mjs @@ -45,8 +45,8 @@ export function resolveDeepseekNoThinkProxyBaseUrl(env = process.env) { const host = String( env.MEMIND_DEEPSEEK_NO_THINK_HOST ?? env.MEMIND_GOOSED_HOST_GATEWAY - ?? 'host.lima.internal', - ).trim() || 'host.lima.internal'; + ?? 'host.docker.internal', + ).trim() || 'host.docker.internal'; const port = resolveDeepseekNoThinkListenPort(env); return `http://${host}:${port}/v1`; } @@ -57,8 +57,8 @@ export function resolveMoonshotCompatProxyBaseUrl(env = process.env) { const host = String( env.MEMIND_DEEPSEEK_NO_THINK_HOST ?? env.MEMIND_GOOSED_HOST_GATEWAY - ?? 'host.lima.internal', - ).trim() || 'host.lima.internal'; + ?? 'host.docker.internal', + ).trim() || 'host.docker.internal'; const port = resolveDeepseekNoThinkListenPort(env); return `http://${host}:${port}/moonshot/v1`; } @@ -98,7 +98,7 @@ export function injectDeepseekThinkingDisabled(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) { return { body, injected: false }; } - if (body.thinking && typeof body.thinking === 'object') { + if (body.thinking?.type === 'disabled') { return { body, injected: false }; } return { diff --git a/deepseek-no-think-proxy.test.mjs b/deepseek-no-think-proxy.test.mjs index efd3912..72a3481 100644 --- a/deepseek-no-think-proxy.test.mjs +++ b/deepseek-no-think-proxy.test.mjs @@ -22,13 +22,13 @@ test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () => assert.equal(body.model, 'deepseek-v4-flash'); }); -test('injectDeepseekThinkingDisabled preserves explicit thinking config', () => { +test('injectDeepseekThinkingDisabled overrides enabled thinking config', () => { const { body, injected } = injectDeepseekThinkingDisabled({ model: 'deepseek-v4-pro', thinking: { type: 'enabled' }, }); - assert.equal(injected, false); - assert.deepEqual(body.thinking, { type: 'enabled' }); + assert.equal(injected, true); + assert.deepEqual(body.thinking, { type: 'disabled' }); }); test('deepseekDisableThinkingEnabled defaults on for local runtime profile', () => { @@ -67,7 +67,7 @@ test('moonshotToolSchemaCompatEnabled defaults on for local runtime profiles', ( test('resolveDeepseekNoThinkProxyBaseUrl uses host gateway defaults', () => { assert.equal( resolveDeepseekNoThinkProxyBaseUrl({}), - 'http://host.lima.internal:18036/v1', + 'http://host.docker.internal:18036/v1', ); assert.equal( resolveDeepseekNoThinkProxyBaseUrl({ @@ -83,7 +83,7 @@ test('Moonshot compat routing recognizes official endpoints and uses a distinct assert.equal(isMoonshotApiUrl('https://api.deepseek.com/v1'), false); assert.equal( resolveMoonshotCompatProxyBaseUrl({}), - 'http://host.lima.internal:18036/moonshot/v1', + 'http://host.docker.internal:18036/moonshot/v1', ); }); diff --git a/deploy/orchestrator/compose.yaml b/deploy/orchestrator/compose.yaml index 58880eb..6d8b9f1 100644 --- a/deploy/orchestrator/compose.yaml +++ b/deploy/orchestrator/compose.yaml @@ -54,6 +54,9 @@ services: image: memind/workflow-orchestrator:local restart: unless-stopped command: ["node", "worker.mjs"] + # Image HEALTHCHECK probes orchestrator :8093; worker has no HTTP listener. + healthcheck: + disable: true depends_on: orchestrator: condition: service_started @@ -64,6 +67,8 @@ services: MEMIND_EXECUTOR_WORKER_ID: ${MEMIND_EXECUTOR_GOOSED_WORKER_ID:-colima-goosed-1} MEMIND_EXECUTOR_GOOSED_URL: ${MEMIND_EXECUTOR_GOOSED_URL:-https://host.docker.internal:18006} MEMIND_EXECUTOR_GOOSED_TOKEN: ${MEMIND_EXECUTOR_GOOSED_TOKEN:-} + MEMIND_EXECUTOR_GOOSED_PROVIDER: ${MEMIND_EXECUTOR_GOOSED_PROVIDER:-} + MEMIND_EXECUTOR_GOOSED_MODEL: ${MEMIND_EXECUTOR_GOOSED_MODEL:-} MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED: "1" MEMIND_EXECUTOR_GOOSED_TLS_INSECURE: ${MEMIND_EXECUTOR_GOOSED_TLS_INSECURE:-1} MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON: ${MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON:-{}} diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md new file mode 100644 index 0000000..fb6ea57 --- /dev/null +++ b/docs/production-release-guardian.md @@ -0,0 +1,549 @@ +# 生产发布守门员 + +> 适用目标:生产 `103` Portal runtime 及其共享链路。 +> 规则状态:已确认为生产发布的强制验收规范。 +> 当前实施状态:场景和证据规范已建立;自动场景执行器、机器报告和发布脚本硬阻断仍需按本文落地。在三者完成并验证前,不得宣称生产发布已经通过本守门员。 + +## 1. 目标 + +本守门员将生产发布从“运行若干测试后人工判断”升级为绑定同一源码和同一产物的可审计硬闸门: + +1. 只有完整、干净、可追溯的 `main` 可以成为候选版本。 +2. 同一个 `main` commit 构建出的同一个 runtime artifact,必须完成本文规定的全部适用场景。 +3. 结果必须满足 `failed=0`、`skipped=0`、`cleanup_failed=0`。 +4. 确实不受影响的场景只能标记为经过审核的 `not_applicable`,不能用 `skip`、口头说明或发布参数绕过。 +5. 即使机器闸门全部通过,仍须取得用户针对“发布 103”动作本身的明确批准。 +6. 未获得批准时,禁止上传、切换 runtime、重启服务或执行任何生产写操作。 + +## 2. 核心术语 + +| 术语 | 含义 | +|---|---| +| Candidate commit | 已进入远端 `main`、CI 通过、准备构建发布包的完整 Git SHA | +| Candidate artifact | 由 Candidate commit 构建的 Portal runtime 包及其 SHA256 | +| Scenario family | 本文定义的一条业务或运行时场景;可包含多个参数化 case | +| Deterministic case | 不依赖真实外部模型输出、可以稳定重复的单元或集成测试 | +| Live canary | 在隔离或预生产环境调用真实 LLM、图片、搜索等服务的最小验证 | +| `not_applicable` | 有完整影响证明并经审核后,确认本次发布不适用的场景状态 | +| Gate report | 绑定 commit、artifact 和环境指纹的机器可读验收报告 | +| Release approval | 所有闸门通过后,用户对本次 103 发布作出的明确授权 | + +## 3. 发布硬规则 + +### 3.1 允许发布的唯一条件 + +以下条件必须同时成立: + +- Candidate commit 位于完整 `main`,工作区干净,不落后 `origin/main`。 +- Candidate commit 已在远端 CI 通过,且没有未合并的关键变更。 +- Candidate artifact 的 SHA256 与 Gate report 完全一致。 +- 所有适用场景均为 `passed`。 +- 没有 `failed`、`skipped`、`blocked`、`unknown` 或未执行场景。 +- 所有 `not_applicable` 均具有有效的影响评估和审核记录。 +- 测试数据和临时资源清理完成。 +- Gate report 未过期;默认有效期不超过 4 小时。 +- 103 只读预检通过。 +- 用户再次明确批准本次 103 发布。 + +任何一项不满足,发布脚本必须在上传 artifact 之前退出。 + +### 3.2 永远不可豁免的场景 + +下列场景不允许标记为 `not_applicable`: + +- `REL-*` 全部发布、升级、备份和回滚场景。 +- `AUTH-01`、`AUTH-04`、`AUTH-05`、`AUTH-06`。 +- `CHAT-01`、`CHAT-02`、`CHAT-06`、`CHAT-07`、`CHAT-08`、`CHAT-09`。 +- `MEM-08` 用户记忆隔离。 +- `FILE-07` 附件跨用户隔离。 +- `PAGE-08`、`PAGE-09`、`PAGE-10`。 +- `DATA-06`、`DATA-10`、`DATA-12`。 +- `BILL-02`、`BILL-03`、`BILL-04`。 +- `MS-01`、`MS-03`、`MS-04`。 +- `COMP-01` 至 `COMP-04` 既有生产数据兼容场景。 +- `COMP-08` 基线并发和资源泄漏场景。 + +### 3.3 `not_applicable` 的判定 + +只有同时满足以下条件,才允许将场景标记为 `not_applicable`: + +1. 本次 diff 不触达该场景映射的代码、配置、数据库对象、依赖、运行时打包清单或公共契约。 +2. 本次 diff 不触达共享入口,包括 `server.mjs`、鉴权、会话、数据库初始化、runtime 构建、goosed 配置、worker、代理、公共环境变量。 +3. 场景所依赖的生产功能处于明确关闭状态,并且“关闭状态行为”已经通过测试。 +4. 影响分析能够列出 changed paths、依赖链和不受影响的理由。 +5. 审核人对该 Candidate commit 作出明确确认。 + +每条豁免记录必须包含: + +```json +{ + "scenario_id": "SEARCH-04", + "status": "not_applicable", + "commit_sha": "", + "reason": "生产 GitHub Search 功能关闭,且本次变更不触达搜索配置或共享网关", + "changed_paths_checked": ["..."], + "dependency_evidence": ["..."], + "reviewed_by": "", + "reviewed_at": "" +} +``` + +如果无法确定是否受影响,默认必须执行,不得豁免。 + +## 4. 分层闸门 + +| Gate | 名称 | 环境 | 主要产物 | 失败处理 | +|---|---|---|---|---| +| G0 | 源码与 CI | 本地 + Git 远端 | commit 证明、CI 状态 | 阻断 | +| G1 | 确定性测试 | 隔离本地副本 | 单元/集成/回归结果 | 阻断 | +| G2 | API 全栈场景 | 隔离全栈 | 场景报告、数据库和文件断言 | 阻断 | +| G3 | 浏览器场景 | 隔离全栈 | Playwright 报告、截图、控制台日志 | 阻断 | +| G4 | 真实服务 Canary | 预生产 | LLM/图片/搜索/Deep Search 结果 | 阻断 | +| G5 | 升级与回滚演练 | 脱敏生产快照 | 升级、持久化、回滚证据 | 阻断 | +| G6 | 103 只读预检 | 生产只读 | 健康、任务、锁、空间、备份检查 | 阻断 | +| G7 | 人工批准 | 发布前 | 明确批准记录 | 未批准不得发布 | +| G8 | 发布后烟雾与自动回滚 | 生产 | 最小业务验收 | 失败立即回滚 | + +G8 不能替代 G0-G7;发布后健康检查也不能为发布前缺失的场景补票。 + +## 5. 测试环境和数据隔离 + +### 5.1 禁止测试连接生产 + +全场景执行器必须在代码层拒绝以下目标: + +- `58.38.22.103` +- `m.tkmind.cn` +- 生产 MySQL、PostgreSQL、Redis、SearXNG、Deep Search 和图片服务地址 +- 生产用户工作区、发布目录和对象存储 + +G6 仅允许明确列出的只读检查;不得创建会话、页面、订单、问卷、记忆、充值或 Agent Run。 + +### 5.2 隔离要求 + +每次执行必须使用: + +- 唯一 `run_id` +- 独立测试数据库或独立 schema +- 独立用户工作区和公开页根目录 +- 专用普通用户 A、普通用户 B、creator、developer、admin、低余额用户 +- 专用微信绑定账号和模拟支付数据 +- 独立 Portal、MindSpace、goosed、worker 端口 +- 可控的 LLM、图片、搜索 Stub,以及单独的真实服务 Canary 凭据 + +测试结束必须删除或归档: + +- 测试会话和 Agent Run +- 用户记忆 +- 上传附件和生成图片 +- MindSpace 页面、缩略图和 Conversation Package +- Page Data dataset、policy 和测试行 +- 计费、充值、微信和 Plaza 测试记录 +- 临时数据库/schema、容器、进程和日志 + +清理失败等同测试失败。 + +## 6. 场景用例标准 + +每个场景文件必须具备: + +```yaml +id: MEM-02 +name: 新会话召回用户偏好 +gate: G2 +owners: [memory, chat] +impact_paths: + - memory-v2*.mjs + - chat-intent-router.mjs +preconditions: + - user_a 已保存一条确定性偏好 +steps: + - 新建会话 + - 使用不同措辞询问偏好 +assertions: + - 路由启用 memory_recall + - 回复包含目标事实 + - 不包含 user_b 的记忆 +cleanup: + - 删除本次测试记忆和会话 +evidence: + - API 响应 + - memory query trace + - database assertion +``` + +LLM 场景不得只断言固定文案,应优先断言: + +- 路由、skill、tool 和 Provider 选择 +- 工具请求/响应是否真实发生 +- 数据库、文件和 artifact 是否正确产生 +- 数字结果和标准 fixture 是否一致 +- 用户可见结果是否包含关键事实和可访问链接 +- 不应出现的敏感信息、内部提示和旧资产是否缺失 + +## 7. 场景目录 + +以下共 19 个场景包、160 个场景族。每个场景族可以在场景文件中继续扩展参数化 case,但不得减少本文要求。 + +### 7.1 发布与运行时(10) + +- **REL-01**:候选来源必须是干净、非 detached 的完整 `main`;记录完整 SHA。 +- **REL-02**:候选不落后 `origin/main`,已进入远端且 CI 通过,没有未合并关键变更。 +- **REL-03**:同一 commit 可重复生成可追踪的 manifest 和 runtime artifact SHA256。 +- **REL-04**:artifact 必需文件完整,不包含开发垃圾、运行态数据、源码备份或密钥。 +- **REL-05**:前端和 runtime 不包含 localhost、错误内网地址或错误公共域名。 +- **REL-06**:使用生产同构配置冷启动,数据库初始化和 Portal 健康检查通过。 +- **REL-07**:从脱敏生产数据库快照升级,DDL 成功、数据保留且重复启动幂等。 +- **REL-08**:9 个 goosed、worker、Portal 和隧道全部指向并识别新 runtime。 +- **REL-09**:注入启动或健康检查失败后,完整恢复旧 live 目录和旧 release。 +- **REL-10**:活动任务排空、备份一致、上传中断和切换前失败均不影响当前版本。 + +### 7.2 登录、账号和权限(8) + +- **AUTH-01**:正确账号密码登录,返回合法 Cookie 和用户信息。 +- **AUTH-02**:错误密码、连续错误尝试和限流行为正确。 +- **AUTH-03**:Portal 重启后合法会话状态符合配置,不出现幽灵登录。 +- **AUTH-04**:登出清除 Cookie,旧 Cookie 无法访问受保护 API。 +- **AUTH-05**:用户 A 无法访问用户 B 的会话、附件、页面和私有数据。 +- **AUTH-06**:普通用户无法访问管理员接口或提升自身权限。 +- **AUTH-07**:访问策略 `off`、`enforce` 和 kill switch 三种模式符合预期。 +- **AUTH-08**:路径穿越、XSS、SQL 注入和敏感配置泄露防护有效。 + +### 7.3 聊天、会话和流式恢复(10) + +- **CHAT-01**:普通问候进入 Direct Chat 并返回用户可见回复。 +- **CHAT-02**:页面、文件和工具任务进入 Agent 路径。 +- **CHAT-03**:实时信息问题进入 Web Search,而不是使用过期直接回答。 +- **CHAT-04**:多轮任务及“继续/确认”保持在原 Agent 会话。 +- **CHAT-05**:明确新会话与自动复用会话的行为正确。 +- **CHAT-06**:Finish 与最终消息正确 merge,不丢用户或助手消息。 +- **CHAT-07**:SSE 中断后按 Portal 游标续播,不重放、不漏事件。 +- **CHAT-08**:Portal replay ID 不直接作为 Goose `Last-Event-ID`。 +- **CHAT-09**:SSE 不可用时,轮询能够恢复 Agent 最终状态和消息。 +- **CHAT-10**:重复请求、失败重试、取消和损坏 tool history 修复正确。 + +### 7.4 用户记忆与记忆召回(12) + +- **MEM-01**:用户明确要求“记住”后成功保存偏好。 +- **MEM-02**:新会话能够准确召回已保存偏好。 +- **MEM-03**:换一种表达仍能通过语义检索召回正确记忆。 +- **MEM-04**:能够召回历史会话中的具体事实和讨论主题。 +- **MEM-05**:偏好变更时新值生效,不同时注入冲突的新旧内容。 +- **MEM-06**:用户要求遗忘后,后续无法继续召回目标记忆。 +- **MEM-07**:无关问题不注入无关记忆。 +- **MEM-08**:两个用户的长期记忆和历史会话完全隔离。 +- **MEM-09**:敏感信息遵循披露、保存、拒绝和删除策略。 +- **MEM-10**:候选记忆、确认、晋升、过期和作用域生命周期正确。 +- **MEM-11**:Memory V2 不可用时按配置降级到 legacy,核心聊天仍可用。 +- **MEM-12**:大量记忆按相关性和 token 限额截断,并保留来源追踪。 + +### 7.5 文件和附件基础能力(7) + +- **FILE-01**:图片上传、预览、关联会话并可被 Agent 读取。 +- **FILE-02**:XLSX 上传、解析和附件选择正确。 +- **FILE-03**:CSV 的编码、分隔符、中文字段和空值解析正确。 +- **FILE-04**:PDF、DOCX 文本抽取和文件元数据正确。 +- **FILE-05**:多附件的选择、顺序、取消选择和会话复用正确。 +- **FILE-06**:损坏文件、错误 MIME、超限文件和不支持格式明确拒绝。 +- **FILE-07**:附件访问令牌过期后失效,跨用户访问被拒绝。 + +### 7.6 根据图片生成页面(8) + +- **IMGPG-01**:单张产品或风景图片生成响应式页面。 +- **IMGPG-02**:截图经过视觉识别和 OCR 后生成结构相符的页面。 +- **IMGPG-03**:多张图片按用户指定顺序生成图集或内容页面。 +- **IMGPG-04**:图片与文字要求共同决定页面结构、文案和风格。 +- **IMGPG-05**:页面引用合法上传资产,不包含本地绝对路径或失效 URL。 +- **IMGPG-06**:公开页、封面、缩略图、OG 和分享链接完整。 +- **IMGPG-07**:后续修改文案或布局时保留原图片和页面地址。 +- **IMGPG-08**:图片不可读或用户无权限时明确失败,不伪造识别结果。 + +### 7.7 Excel/CSV 分析并生成页面(10) + +- **XLS-01**:单工作表生成指标摘要和可访问页面。 +- **XLS-02**:多工作表关联分析得到 fixture 规定的结果。 +- **XLS-03**:公式、日期、货币和百分比计算与标准答案一致。 +- **XLS-04**:合并单元格、空行、脏数据和重复数据处理正确。 +- **XLS-05**:页面 KPI 和图表数值可追溯到 Excel 原始数据。 +- **XLS-06**:大文件分页、采样、超时和内存限制符合阈值。 +- **XLS-07**:XLSX、XLS、CSV 支持范围和降级提示准确。 +- **XLS-08**:后续对话修改分析维度,无需重新上传文件。 +- **XLS-09**:页面保留原文件下载入口和数据来源说明。 +- **XLS-10**:Excel 私有数据默认不公开,用户之间严格隔离。 + +### 7.8 页面生成和 AI 配图(10) + +- **PAGE-01**:纯文字需求生成公开页面并返回可访问链接。 +- **PAGE-02**:长内容整理为适合阅读和分享的富页面。 +- **PAGE-03**:视觉类页面自动判断是否需要配图。 +- **PAGE-04**:强制生图必须使用本轮新生成资产。 +- **PAGE-05**:用户明确不要图片时不得调用生图。 +- **PAGE-06**:生图失败时如实报告,不使用旧图冒充成功。 +- **PAGE-07**:主图、卡片封面和缩略图复用合法资产,避免重复生成。 +- **PAGE-08**:`edit_file` 修改必须 materialize 到 `public/*.html`。 +- **PAGE-09**:多页面任务只更新本轮相关页面,不污染其他页面。 +- **PAGE-10**:文件名冲突、canonical、CSP、公开链接和域名正确。 + +### 7.9 Page Data、问卷和业务后台(12) + +- **DATA-01**:AI 使用调查完成页面、dataset、提交和后台查看。 +- **DATA-02**:客户下单系统前台提交和后台管理完整。 +- **DATA-03**:供应商数据上报、校验和后台查看完整。 +- **DATA-04**:TKMind 功能偏好调查完成交付。 +- **DATA-05**:儿童问卷新增饮食字段且保留历史数据。 +- **DATA-06**:匿名前台可以按 policy 提交,后台必须登录。 +- **DATA-07**:新增、查询、修改、删除和字段校验正确。 +- **DATA-08**:HTML 使用 `updateRow`/`deleteRow` 时权限自动匹配。 +- **DATA-09**:验证码、重复提交、重放和限流有效。 +- **DATA-10**:dataset、page policy 和 workspace 绑定一致。 +- **DATA-11**:remote 模式同步,storage 缺失时回退 workspace。 +- **DATA-12**:跨用户、SQL 注入、越权字段和审计日志验证通过。 + +### 7.10 搜索和 Deep Search(8) + +- **SEARCH-01**:实时信息通过 Web Search 获取并提供可验证来源。 +- **SEARCH-02**:SearXNG 查询和结果解析正确。 +- **SEARCH-03**:网页 Reader 抽取正文并处理失败页面。 +- **SEARCH-04**:GitHub Search 启用和禁用状态符合配置。 +- **SEARCH-05**:Deep Search 使用真实 LLM 网关完成最小研究任务。 +- **SEARCH-06**:密钥不匹配时明确返回鉴权失败,不能以 health 200 代替功能成功。 +- **SEARCH-07**:搜索超时、取消和 Provider fallback 正确。 +- **SEARCH-08**:容器内 loopback 地址正确重写为 host gateway。 + +### 7.11 Agent、goosed、worker 和代码执行(9) + +- **AGENT-01**:普通 Agent 任务可由 goosed 成功执行。 +- **AGENT-02**:并发任务分配和 session affinity 正确。 +- **AGENT-03**:Portal 与 worker runtime/build identity 匹配。 +- **AGENT-04**:identity 不匹配时 fail closed,不误领任务。 +- **AGENT-05**:任务重试、幂等和重复消费防护正确。 +- **AGENT-06**:worker 重启后任务恢复或形成明确失败终态。 +- **AGENT-07**:获得授权的用户可以执行 Aider 开发任务。 +- **AGENT-08**:未授权用户和非允许 task type 被拒绝。 +- **AGENT-09**:代码产物正确交付,不能逃逸用户工作区。 + +### 7.12 日程和提醒(5) + +- **SCHED-01**:按照用户时区创建提醒。 +- **SCHED-02**:模糊时间要求澄清,不擅自确定错误时间。 +- **SCHED-03**:修改和取消提醒正确。 +- **SCHED-04**:worker 只发送一次,失败重试不重复通知。 +- **SCHED-05**:服务重启后提醒仍存在并按计划执行。 + +### 7.13 微信渠道(8) + +- **WX-01**:签名和连通性验证正确。 +- **WX-02**:重复消息 ID 不重复执行。 +- **WX-03**:绑定用户聊天及用户数据隔离正确。 +- **WX-04**:页面交付返回正确公开链接。 +- **WX-05**:图片需求发送微信原生图片消息。 +- **WX-06**:服务号页面使用本轮新缩略图,缺失时按规则修复。 +- **WX-07**:OAuth 回调、return URL 和 state 校验正确。 +- **WX-08**:支付通知签名、幂等和渠道隔离通过沙箱数据验证。 + +### 7.14 计费、额度和充值(7) + +- **BILL-01**:免费、订阅和管理员权益计算正确。 +- **BILL-02**:Direct Chat 和 Agent 分别只扣费一次。 +- **BILL-03**:失败、取消和超时任务不重复扣费。 +- **BILL-04**:SSE 重连和消息重放不重复结算。 +- **BILL-05**:并发任务下余额扣减保持原子性。 +- **BILL-06**:充值回调签名和幂等正确。 +- **BILL-07**:余额不足时拒绝执行且不创建残留任务。 + +### 7.15 MindSpace 和交付物(8) + +- **MS-01**:MindSpace `/health` 和 `/mindspace/v1/contract` 正常。 +- **MS-02**:local/remote adapter 的核心行为一致。 +- **MS-03**:公开页访问、canonical、OG 和分享正确。 +- **MS-04**:页面同步、缩略图和 workspace fallback 正确。 +- **MS-05**:Conversation Package 创建、校验和历史回填正确。 +- **MS-06**:DOCX 和长图下载可用,链接不指向本地地址。 +- **MS-07**:内容扫描、CSP 和危险脚本处理正确。 +- **MS-08**:删除、清理和 purge 不留下孤立资源。 + +### 7.16 Plaza 集成(5) + +- **PLAZA-01**:页面发布到 Plaza 后内容和链接正确。 +- **PLAZA-02**:发现、搜索和 SEO 数据正确。 +- **PLAZA-03**:点赞、评论、关注和权限正确。 +- **PLAZA-04**:推荐和排序在固定 fixture 下稳定。 +- **PLAZA-05**:页面删除或转私密后不再公开访问。 + +### 7.17 配置、功能开关和可观测性(7) + +- **CFG-01**:Orchestrator 关闭时不成为 Portal 启动或请求依赖。 +- **CFG-02**:Orchestrator shadow 模式不改变用户结果。 +- **CFG-03**:kill switch 可以立即阻断新的受控执行。 +- **CFG-04**:Analytics 关闭时零上报,开启时上下文正确。 +- **CFG-05**:System Disclosure 的 shadow/enforce 行为正确。 +- **CFG-06**:环境变量迁移到数据库配置且重复执行幂等。 +- **CFG-07**:日志、指标和审计可追踪,且不泄露密钥和用户隐私。 + +### 7.18 浏览器和移动端(8) + +- **UI-01**:桌面端登录、聊天和历史记录完整。 +- **UI-02**:移动端聊天、流式消息和 Finish 完整。 +- **UI-03**:图片、Excel 和其他附件上传交互正确。 +- **UI-04**:页面预览、修改、发布和分享流程正确。 +- **UI-05**:公开 Page Data 表单可以真实提交并看到结果。 +- **UI-06**:空状态、加载、错误、重试和禁用状态完整。 +- **UI-07**:刷新、前进后退和重新进入会话不丢状态。 +- **UI-08**:无控制台错误、无布局重叠,并通过基础可访问性检查。 + +### 7.19 生产兼容、故障恢复和性能(8) + +- **COMP-01**:脱敏生产旧会话升级后可以继续聊天。 +- **COMP-02**:现有公开页面升级后仍可访问且内容未损坏。 +- **COMP-03**:现有 Page Data 数据、policy 和表单升级后仍可使用。 +- **COMP-04**:现有用户记忆升级后仍可召回。 +- **COMP-05**:单个 goosed 容器故障时隔离并恢复。 +- **COMP-06**:数据库短暂不可用后服务能够恢复并保持一致性。 +- **COMP-07**:图片、搜索或记忆 Provider 故障不拖垮核心聊天。 +- **COMP-08**:并发聊天、文件分析和页面生成满足延迟、错误率和资源泄漏阈值。 + +## 8. 既有场景迁移 + +现有 `scenarios/*.json` 场景必须保留,并映射到新编号: + +| 既有场景 | 新场景 | +|---|---| +| `ai-usage-survey` | DATA-01 | +| `customer-order-system` | DATA-02 | +| `dev-logout` | AUTH-04 | +| `john2-suzhou-page` | PAGE-01、MS-03 | +| `john4-children-hobby-diet-update` | DATA-05 | +| `supplier-data-report` | DATA-03 | +| `suzhou-page` | PAGE-01、PAGE-08 | +| `tkmind-feature-survey` | DATA-04 | +| `event-registration-page-data` | DATA-06、DATA-07、DATA-10 | +| `event-registration-page-data-repair` | DATA-07、DATA-10、DATA-11 | + +现有执行器只支持 `login`、`logout`、`chat`。实施时至少增加: + +- `seed`、`cleanup` +- `grant`、`revoke` +- `upload`、`select_asset` +- `disconnect_stream`、`reconnect_stream` +- `assert_memory` +- `assert_page` +- `assert_dataset` +- `assert_billing` +- `assert_audit` +- `restart_service` +- `inject_fault` +- `browser` +- `rollback` + +## 9. 建议命令和报告接口 + +实施后统一提供以下入口: + +```bash +npm run test:release-gate:deterministic +npm run test:release-gate:scenarios +npm run test:release-gate:browser +npm run test:release-gate:providers +npm run test:release-gate:upgrade +npm run test:release-gate:all +``` + +`test:release-gate:all` 生成: + +```text +.release-gate// +├── report.json +├── report.md +├── junit.xml +├── artifact.sha256 +├── environment.json +├── scenarios/ +├── screenshots/ +└── logs/ +``` + +`report.json` 至少包含: + +```json +{ + "schema_version": 1, + "commit_sha": "", + "branch": "main", + "artifact_sha256": "", + "started_at": "", + "completed_at": "", + "expires_at": "", + "environment_fingerprint": "", + "summary": { + "required": 160, + "passed": 160, + "not_applicable": 0, + "failed": 0, + "skipped": 0, + "cleanup_failed": 0 + }, + "scenarios": [], + "exemptions": [], + "approved_for_release": false +} +``` + +如果存在合法 `not_applicable`,`required` 仍为 160,且必须满足: + +```text +passed + not_applicable = required +failed = skipped = cleanup_failed = 0 +``` + +## 10. 发布脚本的最终阻断点 + +`scripts/release-portal-runtime-prod.sh` 在实施本守门员后必须按以下顺序执行: + +1. `check-release-ready.sh` +2. 获取完整 `main` SHA 和远端 CI 状态 +3. 构建 Candidate artifact +4. 计算 artifact SHA256 +5. 读取对应 Gate report +6. 校验 commit、artifact、环境、有效期和场景汇总 +7. 校验全部 `not_applicable` 记录 +8. 执行 103 只读预检 +9. 请求本次发布的明确人工批准 +10. 批准后才允许上传 + +以下情况必须直接退出: + +- 使用 `--skip-tests` +- 使用范围绕过变量 +- report 不存在、过期或解析失败 +- commit 或 artifact SHA 不一致 +- 任一场景失败、跳过、未知或清理失败 +- 豁免记录缺少审核信息 +- 103 有活动任务且未完成排空 +- 备份、磁盘空间、数据库锁或回滚预检失败 + +## 11. 发布后的最小保护 + +生产切换后立即执行只产生最小业务影响的烟雾验证: + +- Portal 本地和公网健康检查 +- 登录与登出 +- 新建最小聊天并收到回复 +- SSE 终态 +- 读取一张既有公开页 +- 读取一个既有 Page Data 页面 +- MindSpace health/contract +- 9 个 goosed、worker 和隧道状态 +- 日志中无数据库迁移、鉴权、循环重启和大面积 5xx + +任何一项失败,必须自动回滚到 Gate report 记录的旧 release,并保存失败证据。 + +## 12. 审核与维护 + +1. 新增生产功能时,必须同时新增场景或明确映射到已有场景。 +2. 删除场景必须说明对应生产功能已删除,并经过审核。 +3. 事故修复必须把复现路径加入本守门员或 `docs/regression-guards/`。 +4. 场景实现不能只检查 HTTP 200,必须验证真实业务结果。 +5. 任何脚本都不得自动把失败改成 `not_applicable`。 +6. 发布人不能单独审批自己提出的豁免。 +7. Gate report 和发布 manifest 应按 release ID 归档。 +8. 本文、`PRODUCTION_RELEASE_RULES.md` 和发布脚本的规则必须保持一致。 diff --git a/docs/regression-guards/README.md b/docs/regression-guards/README.md index ee1e322..a710577 100644 --- a/docs/regression-guards/README.md +++ b/docs/regression-guards/README.md @@ -2,6 +2,8 @@ 本目录记录**已验证、不可轻易回退**的行为。适用于 Codex、Cloud、Cursor 及任何人工作前的自检。 +生产发布的跨模块完整场景和硬闸门不在本目录重复维护,统一以 [生产发布守门员](../production-release-guardian.md) 为准;本目录中的回归守卫必须映射并纳入该发布守门员。 + ## 文档 | 文档 | 保护内容 | diff --git a/docs/发包必看.md b/docs/发包必看.md index 0dc5654..18550b2 100644 --- a/docs/发包必看.md +++ b/docs/发包必看.md @@ -2,6 +2,8 @@ > 适用范围:`test-memind` Portal runtime 发布、`goosed-prod` 镜像更新、103/105 生产链路排障。 > 这份文档是发布前强制阅读的事故经验清单。后续发包如果绕过这里,容易把 2026-06-27 的问题重新带回生产。 +> +> `103` 生产发布还必须完整通过 [生产发布守门员](production-release-guardian.md)。守门员自动执行器、机器报告和发布脚本硬阻断全部落地前,不允许将现有脚本视为满足新的生产发布条件。 ## 0. 先确认边界 @@ -32,7 +34,7 @@ ln -s /Users/john/PycharmProjects/test/test-memind/node_modules /Users/john/Pych rm -f /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modules ``` -5. 发包前至少跑: +5. 发包前必须完成生产发布守门员;其中现有基础验证至少包括: ```bash node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs @@ -43,12 +45,14 @@ npm run check:mindspace-public-links ## 2. Portal runtime 发布唯一流程 -只使用: +生产发布守门员落地、完整 Gate report 通过且获得本次明确人工批准后,只使用: ```bash -bash scripts/release-portal-runtime-prod.sh --skip-tests --yes +bash scripts/release-portal-runtime-prod.sh --yes ``` +生产 `103` 禁止使用 `--skip-tests` 或任何范围绕过变量。本文历史记录中的旧绕过命令只用于事故复盘,不构成当前发布授权。 + 这个脚本必须完成这些动作: - 本机构建 `.runtime/portal`。 diff --git a/llm-providers.mjs b/llm-providers.mjs index 8e46cb5..fbf603f 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -760,6 +760,18 @@ export async function updateSessionProvider( } } +function isMissingCustomProviderResponse(status, text) { + return status === 404 || /provider not found/i.test(text); +} + +function resolveGoosedProviderId(profile, goosedProviderId) { + if (goosedProviderId) return goosedProviderId; + if (profile.providerKind === 'custom') { + return profile.goosedProviderId ?? profile.providerId; + } + return profile.providerId; +} + async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) { const headers = profile.relayProvider ? { 'X-Provider': profile.relayProvider } @@ -788,9 +800,11 @@ async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetch fetchImpl, ); if (upstream.ok) return profile.goosedProviderId; - // First-time ensure: fixed ids may 404 until created. - if (upstream.status !== 404) { - const text = await upstream.text().catch(() => ''); + const text = await upstream.text().catch(() => ''); + // First-time ensure: older TKMind Agent builds may report a missing fixed id + // as 500 with a JSON "Provider not found" message instead of HTTP 404. + const providerMissing = isMissingCustomProviderResponse(upstream.status, text); + if (!providerMissing) { throw new Error(`更新 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`); } } @@ -863,8 +877,9 @@ async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProvider { method: 'DELETE' }, fetchImpl, ); - if (!upstream.ok && upstream.status !== 404) { + if (!upstream.ok) { const text = await upstream.text().catch(() => ''); + if (isMissingCustomProviderResponse(upstream.status, text)) return; throw new Error(`删除 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`); } } @@ -1157,10 +1172,7 @@ export function createLlmProviderService( try { const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl); const profile = profileFromRow(row, decryptRow); - const providerId = - profile.providerKind === 'custom' - ? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId) - : profile.providerId; + const providerId = resolveGoosedProviderId(profile, goosedProviderId); return { ok: true, providerId, @@ -1355,10 +1367,7 @@ export function createLlmProviderService( } try { const goosedProviderId = await syncRow(row, fetchImpl); - const providerId = - profile.providerKind === 'custom' - ? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId) - : profile.providerId; + const providerId = resolveGoosedProviderId(profile, goosedProviderId); return { ok: true, providerId, diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index 3646545..c02ac1e 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -212,6 +212,52 @@ test('create custom relay profile registers goosed custom provider', async () => assert.equal(rows[0].provider_kind, 'custom'); }); +test('deleteKey treats a missing TKMind custom provider as already deleted', async () => { + const row = { + id: 'key-relay', + provider_id: CUSTOM_PROVIDER_ID, + provider_kind: 'custom', + goosed_provider_id: 'custom_relay_buyer_ollama', + name: 'Relay Buyer Ollama', + status: 'active', + is_selected: 0, + }; + let deleted = false; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) { + return [[params[0] === row.id ? row : null].filter(Boolean)]; + } + if (sql.includes('DELETE FROM h5_llm_provider_keys WHERE id = ?')) { + deleted = params[0] === row.id; + return [[]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const mockFetch = async (_url, init = {}) => { + assert.equal(init.method, 'DELETE'); + return { + ok: false, + status: 500, + text: async () => JSON.stringify({ + message: 'Provider not found: custom_relay_buyer_ollama', + }), + }; + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + apiFetchImpl: mockFetch, + }); + + const result = await service.deleteKey(row.id); + + assert.equal(result.ok, true); + assert.equal(deleted, true); +}); + test('selectKey rejects disabled profile', async () => { const row = { id: 'key-1', @@ -774,7 +820,13 @@ test('syncProfileToGoosed routes local DeepSeek through the no-thinking proxy', init.method === 'PUT' && href.includes(`/config/custom-providers/${MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID}`) ) { - return { ok: false, status: 404, text: async () => '' }; + return { + ok: false, + status: 500, + text: async () => JSON.stringify({ + message: `Provider not found: ${MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID}`, + }), + }; } if (init.method === 'POST' && href.endsWith('/config/custom-providers')) { return { diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index daa1328..cf0ec8b 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -166,6 +166,35 @@ test('MindSearch uses the container-visible bundled MCP directory', () => { assert.equal(extension.args[0], '/opt/portal/tkmind-search-mcp.mjs'); }); +test('MindSearch keeps container MCP path even when sandboxMcp is absent', () => { + const previousContainerized = process.env.GOOSED_MCP_CONTAINERIZED; + const previousServerPath = process.env.GOOSED_MCP_SERVER_PATH; + process.env.GOOSED_MCP_CONTAINERIZED = '1'; + process.env.GOOSED_MCP_SERVER_PATH = '/opt/portal/mindspace-sandbox-mcp.mjs'; + try { + const policy = buildAgentExtensionPolicy( + { + ...DEFAULT_USER_CAPABILITIES, + search_external: true, + }, + { + mindSearchConfig: { + enabled: true, + mode: 'assist', + providers: {}, + }, + }, + ); + const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-search'); + assert.equal(extension.args[0], '/opt/portal/tkmind-search-mcp.mjs'); + } finally { + if (previousContainerized === undefined) delete process.env.GOOSED_MCP_CONTAINERIZED; + else process.env.GOOSED_MCP_CONTAINERIZED = previousContainerized; + if (previousServerPath === undefined) delete process.env.GOOSED_MCP_SERVER_PATH; + else process.env.GOOSED_MCP_SERVER_PATH = previousServerPath; + } +}); + test('SearXNG adapter normalizes provider results without requiring a live network', async () => { const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) }); assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 }); diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 41c7071..d208f67 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -19,6 +19,7 @@ import { import { listPageAccessPolicies } from './page-data-policy-store.mjs'; import { buildPublicUrl, resolvePageDataDeliveryBaseUrl } from './user-publish.mjs'; import { detectProhibitedBrowserStorage } from './mindspace-browser-storage-policy.mjs'; +import { buildPageDataValidationRepairPrompt } from './page-data-validation-suggestions.mjs'; export { inferPageDataBindAccessMode }; @@ -436,31 +437,13 @@ export function buildPageDataCollectRepairPrompt({ htmlIssues = [], unboundFiles = [], } = {}) { - const lines = [ - '【系统交付门禁】本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。请立即按 page-data-collect 技能修复:', - '1. load_skill → page-data-collect', - '2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })', - '3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端', - '4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)', - '5. 向用户交付的链接必须使用 Portal(本地 http://127.0.0.1:8081),不要用 Vite 5173', - ]; - if (htmlIssues.length) { - lines.push('', 'HTML 问题:'); - for (const item of htmlIssues) { - lines.push(`- ${item.relativePath}: ${item.issue}`); - } - } - if (unboundFiles.length) { - lines.push('', '尚未 bind 的 Page Data 页面:'); - for (const file of unboundFiles) { - const reasons = Array.isArray(file.bindReasons) && file.bindReasons.length - ? ` (${file.bindReasons.join(', ')})` - : ''; - lines.push(`- ${file.relativePath}${reasons}`); - } - } - lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。'); - return lines.join('\n'); + // Tier A suggestions are folded into the goosed repair turn so the agent + // receives actionable Chinese remediation, not only raw issue ids. + return buildPageDataValidationRepairPrompt({ + htmlIssues, + unboundFiles, + message: '本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。', + }); } export function shouldRetryPageDataCollectReply({ diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index fdd81a1..7fdd9a8 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -243,6 +243,7 @@ test('buildPageDataCollectRepairPrompt mentions bind and forbids localStorage', }); assert.match(prompt, /private_data_bind_workspace_page/); assert.match(prompt, /localStorage/); + assert.match(prompt, /纠正建议|FORBIDDEN_LOCAL_STORAGE/); assert.match(buildPageDataCollectFailureText(), /page-data-collect/); }); diff --git a/ops/src/pages/admin/OrchestratorPage.tsx b/ops/src/pages/admin/OrchestratorPage.tsx index 1c8d795..881434f 100644 --- a/ops/src/pages/admin/OrchestratorPage.tsx +++ b/ops/src/pages/admin/OrchestratorPage.tsx @@ -189,16 +189,20 @@ export function OrchestratorPage() { color: state.runtime.pageDataValidationGate.enabled ? '#2f6f57' : '#8a5a16', }}> {state.runtime.pageDataValidationGate.enabled - ? '强制通过' + ? '强制通过(fail-closed)' : state.runtime.pageDataValidationGate.requested ? '等待 Shadow wiring' - : '仅观察'} + : '仅观察(建议不挡成功)'}

{runtimeMessage}

+

+ 本地/开发默认关闭强制闸门(MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED=0), + 失败时仍会附带纠正建议。生产开启强制审查前需确认 Orchestrator 健康,否则会把本可成功的交付判失败。 +

diff --git a/page-data-validation-suggestions.mjs b/page-data-validation-suggestions.mjs new file mode 100644 index 0000000..24773af --- /dev/null +++ b/page-data-validation-suggestions.mjs @@ -0,0 +1,277 @@ +/** + * Page Data review / correction rollout (审查纠错影响分析): + * + * - Tier A (this module): map failure codes → structured correction suggestions. + * Does not change Agent Run success/failure semantics by itself. + * - Tier B: MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED + * Ops fail-closed switch; local/dev default stays observe-only (0). + * - Tier C (minimal): remediable suggestion codes can be turned into a single + * goosed repair prompt (finish-guard / agent-run one-shot). No unbounded loops. + */ + +const HTML_ISSUE_TO_CODE = Object.freeze({ + forbidden_local_storage: 'FORBIDDEN_LOCAL_STORAGE', + forbidden_local_storage_fallback: 'FORBIDDEN_LOCAL_STORAGE', + forbidden_browser_storage: 'FORBIDDEN_BROWSER_STORAGE', + forbidden_legacy_page_data_api: 'FORBIDDEN_LEGACY_PAGE_DATA_API', + forbidden_legacy_owner_api: 'FORBIDDEN_LEGACY_PAGE_DATA_API', +}); + +/** Codes goosed can reasonably fix in one repair turn. */ +const REMEDIABLE_CODES = new Set([ + 'PAGE_DATA_DELIVERABLE_MISSING', + 'PAGE_DATA_DELIVERY_FAILED', + 'DELIVERABLE_DATA_STORAGE_FORBIDDEN', + 'FORBIDDEN_LOCAL_STORAGE', + 'FORBIDDEN_BROWSER_STORAGE', + 'FORBIDDEN_LEGACY_PAGE_DATA_API', + 'COLUMNS_NOT_ALLOWED', + 'TOOL_GATEWAY_VALIDATION_FAILED', + 'PAGE_DATA_AIDER_FINISH_UNAVAILABLE', +]); + +const SUGGESTION_BY_CODE = Object.freeze({ + PAGE_DATA_DELIVERABLE_MISSING: Object.freeze({ + code: 'PAGE_DATA_DELIVERABLE_MISSING', + title: '缺少可交付的公开页面', + actions: Object.freeze([ + '确认已用 page-data-collect 写出 public/*-survey.html 与(如需要)public/*-admin.html', + '检查页面是否落在 workspace 的 public/ 目录,而不是仅聊天草稿', + '重新说明需求并要求「完整创建并发布」', + ]), + }), + PAGE_DATA_DELIVERY_FAILED: Object.freeze({ + code: 'PAGE_DATA_DELIVERY_FAILED', + title: '页面数据绑定或发布失败', + actions: Object.freeze([ + '确认 dataset 已 private_data_register_dataset 注册', + '对每个公开页调用 private_data_bind_workspace_page(含 password 策略时口令 ≥8 位)', + '检查 .mindspace/page-data-policies 是否生成且列白名单与表单字段一致', + ]), + }), + DELIVERABLE_DATA_STORAGE_FORBIDDEN: Object.freeze({ + code: 'DELIVERABLE_DATA_STORAGE_FORBIDDEN', + title: '页面使用了禁止的数据存储方式', + actions: Object.freeze([ + '删除 localStorage / sessionStorage / IndexedDB 读写', + '改用 /assets/page-data-client.js 与 Page Data 公开 API', + '禁止回退到 /api/page-data 旧 Owner API', + ]), + }), + FORBIDDEN_LOCAL_STORAGE: Object.freeze({ + code: 'FORBIDDEN_LOCAL_STORAGE', + title: '检测到 localStorage 回退', + actions: Object.freeze([ + '移除 localStorage.setItem / getItem', + '报名/问卷提交改为 MindSpacePageData.createClient insert', + '后台列表改为 Page Data read(口令页先 authenticate)', + ]), + }), + FORBIDDEN_BROWSER_STORAGE: Object.freeze({ + code: 'FORBIDDEN_BROWSER_STORAGE', + title: '检测到浏览器本地存储', + actions: Object.freeze([ + '移除 sessionStorage / IndexedDB 等浏览器存储', + '结构化数据只走 Page Data API', + ]), + }), + FORBIDDEN_LEGACY_PAGE_DATA_API: Object.freeze({ + code: 'FORBIDDEN_LEGACY_PAGE_DATA_API', + title: '使用了已移除的 /api/page-data', + actions: Object.freeze([ + '改用 /api/public/pages/:pageId/data/... 公开 API', + '通过 page-data-client.js,不要直连已删除的 Owner API', + ]), + }), + COLUMNS_NOT_ALLOWED: Object.freeze({ + code: 'COLUMNS_NOT_ALLOWED', + title: '字段不在策略白名单中', + actions: Object.freeze([ + '对齐 HTML 表单 name 与 dataset 列名', + '更新 policy 的 insert/read 白名单后重新 bind', + '必要时先 ALTER 表并重新 register_dataset', + ]), + }), + TOOL_GATEWAY_VALIDATION_FAILED: Object.freeze({ + code: 'TOOL_GATEWAY_VALIDATION_FAILED', + title: '代码任务校验未通过', + actions: Object.freeze([ + '根据工具网关校验输出修 HTML / 策略不一致项', + '简单 Page Data 修复可走 code run + aider;复杂改动再升级执行器', + ]), + }), + REQUIRED_REVIEW_EXECUTOR_MISMATCH: Object.freeze({ + code: 'REQUIRED_REVIEW_EXECUTOR_MISMATCH', + title: '独立审查执行器不匹配', + actions: Object.freeze([ + '确认 run 要求的 reviewExecutor 与实际执行器一致', + '在能力与 executor binding 中启用对应审查执行器', + ]), + }), + REQUIRED_EXECUTOR_UNAVAILABLE: Object.freeze({ + code: 'REQUIRED_EXECUTOR_UNAVAILABLE', + title: '所需执行器不可用', + actions: Object.freeze([ + '检查 aider / openhands / goose 绑定与 API Key', + '确认 memindadm 执行器绑定已启用且模型可用', + ]), + }), + PAGE_DATA_AIDER_FINISH_UNAVAILABLE: Object.freeze({ + code: 'PAGE_DATA_AIDER_FINISH_UNAVAILABLE', + title: 'Aider Page Data 收尾不可用', + actions: Object.freeze([ + '改回 Goose + page-data-collect 完成 bind/发布', + '或检查 code run / Aider 配置后重试', + ]), + }), + WORKFLOW_VALIDATION_GATE_UNAVAILABLE: Object.freeze({ + code: 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE', + title: 'Page Data 审查服务不可用', + actions: Object.freeze([ + '确认 Orchestrator(8093)健康且 Shadow wiring 已开', + '本地开发可将 MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED=0 改为仅观察', + '生产强制闸门需 Ops 批准;故障时应先恢复 Orchestrator 再重试交付', + ]), + }), + WORKFLOW_VALIDATION_GATE_REJECTED: Object.freeze({ + code: 'WORKFLOW_VALIDATION_GATE_REJECTED', + title: 'Page Data 审查未通过', + actions: Object.freeze([ + '根据 Native 守卫与失败 check codes 修复页面/策略', + '修复后重新发起交付,不要绕过审查闸门', + ]), + }), + WORKFLOW_VALIDATION_GATE_INCONCLUSIVE: Object.freeze({ + code: 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE', + title: 'Page Data 审查结果不明确', + actions: Object.freeze([ + '确认必选 check(completion / binding / storage_policy)均已实际执行而非 skipped', + '补齐 syncUserPagesOnSuccess 与 validateRunDeliverables 后再交付', + '若仅本地调试,可临时关闭强制闸门改为观察模式', + ]), + }), +}); + +function normalizeCode(value) { + return String(value ?? '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_'); +} + +export function listPageDataValidationSuggestionCodes() { + return Object.keys(SUGGESTION_BY_CODE); +} + +export function resolvePageDataValidationSuggestions(codeOrCodes) { + const values = Array.isArray(codeOrCodes) ? codeOrCodes : [codeOrCodes]; + const seen = new Set(); + const suggestions = []; + for (const raw of values) { + const code = normalizeCode(raw); + if (!code || seen.has(code)) continue; + const item = SUGGESTION_BY_CODE[code]; + if (!item) continue; + seen.add(code); + suggestions.push({ + code: item.code, + title: item.title, + actions: [...item.actions], + }); + } + return suggestions; +} + +export function formatPageDataValidationUserMessage({ + code = null, + codes = null, + message = '', +} = {}) { + const base = String(message ?? '').trim(); + const resolvedCodes = [ + ...(Array.isArray(codes) ? codes : []), + ...(code ? [code] : []), + ]; + const suggestions = resolvePageDataValidationSuggestions(resolvedCodes); + if (!suggestions.length) return base; + const blocks = suggestions.map((item) => { + const lines = item.actions.map((action) => `- ${action}`); + return `建议(${item.title}):\n${lines.join('\n')}`; + }); + if (!base) return blocks.join('\n\n'); + return `${base}\n\n${blocks.join('\n\n')}`; +} + +export function suggestionCodesFromPageDataHtmlIssues(htmlIssues = []) { + const codes = []; + for (const item of Array.isArray(htmlIssues) ? htmlIssues : []) { + const issue = String(item?.issue ?? item ?? '').trim().toLowerCase(); + const mapped = HTML_ISSUE_TO_CODE[issue]; + if (mapped) codes.push(mapped); + } + return codes; +} + +export function isPageDataSuggestionRemediable(codeOrCodes) { + const values = Array.isArray(codeOrCodes) ? codeOrCodes : [codeOrCodes]; + return values.some((value) => REMEDIABLE_CODES.has(normalizeCode(value))); +} + +/** + * Build a goosed-facing repair prompt from Tier A suggestions. + * Intended for one-shot finish-guard / agent-run repair, not unbounded retries. + */ +export function buildPageDataValidationRepairPrompt({ + code = null, + codes = null, + htmlIssues = [], + unboundFiles = [], + message = '', +} = {}) { + const resolvedCodes = [ + ...(Array.isArray(codes) ? codes : []), + ...(code ? [code] : []), + ...suggestionCodesFromPageDataHtmlIssues(htmlIssues), + ]; + const suggestions = resolvePageDataValidationSuggestions(resolvedCodes); + const lines = [ + '【系统纠正建议 → 请立即修复】Page Data 交付未通过。请按 page-data-collect 技能执行下列纠正建议,完成后不要声称已发布,除非 bind 与公开页已就绪:', + '1. load_skill → page-data-collect', + '2. 确保 public/*.html 使用 /assets/page-data-client.js 与 MindSpacePageData.createClient({ apiBase: "/api" })', + '3. 禁止 localStorage / sessionStorage / IndexedDB 与 /api/page-data 旁路', + '4. 对每个 public/*.html 调用 private_data_bind_workspace_page', + ]; + if (String(message ?? '').trim()) { + lines.push('', `失败摘要:${String(message).trim().slice(0, 500)}`); + } + if (suggestions.length) { + lines.push('', '纠正建议:'); + for (const item of suggestions) { + lines.push(`- ${item.title}(${item.code})`); + for (const action of item.actions) { + lines.push(` · ${action}`); + } + } + } + if (Array.isArray(htmlIssues) && htmlIssues.length) { + lines.push('', 'HTML 问题:'); + for (const item of htmlIssues) { + lines.push(`- ${item.relativePath ?? '?'}: ${item.issue ?? item}`); + } + } + if (Array.isArray(unboundFiles) && unboundFiles.length) { + lines.push('', '尚未 bind 的页面:'); + for (const file of unboundFiles) { + lines.push(`- ${file.relativePath ?? file}`); + } + } + lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。'); + return lines.join('\n'); +} + +export const pageDataValidationSuggestionsInternals = { + SUGGESTION_BY_CODE, + HTML_ISSUE_TO_CODE, + REMEDIABLE_CODES, + normalizeCode, +}; diff --git a/page-data-validation-suggestions.test.mjs b/page-data-validation-suggestions.test.mjs new file mode 100644 index 0000000..3cc8230 --- /dev/null +++ b/page-data-validation-suggestions.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildPageDataValidationRepairPrompt, + formatPageDataValidationUserMessage, + isPageDataSuggestionRemediable, + listPageDataValidationSuggestionCodes, + resolvePageDataValidationSuggestions, + suggestionCodesFromPageDataHtmlIssues, +} from './page-data-validation-suggestions.mjs'; + +test('first-batch Page Data suggestion codes are registered', () => { + const codes = listPageDataValidationSuggestionCodes(); + for (const code of [ + 'PAGE_DATA_DELIVERABLE_MISSING', + 'PAGE_DATA_DELIVERY_FAILED', + 'DELIVERABLE_DATA_STORAGE_FORBIDDEN', + 'COLUMNS_NOT_ALLOWED', + 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE', + 'WORKFLOW_VALIDATION_GATE_REJECTED', + 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE', + ]) { + assert.ok(codes.includes(code), `missing ${code}`); + } +}); + +test('resolvePageDataValidationSuggestions dedupes and ignores unknown codes', () => { + const suggestions = resolvePageDataValidationSuggestions([ + 'page_data_deliverable_missing', + 'PAGE_DATA_DELIVERABLE_MISSING', + 'not-a-real-code', + 'columns_not_allowed', + ]); + assert.deepEqual( + suggestions.map((item) => item.code), + ['PAGE_DATA_DELIVERABLE_MISSING', 'COLUMNS_NOT_ALLOWED'], + ); + assert.ok(suggestions[0].actions.length >= 1); +}); + +test('formatPageDataValidationUserMessage appends actionable Chinese suggestions', () => { + const text = formatPageDataValidationUserMessage({ + code: 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE', + message: 'Page Data Orchestrator validation was not observed: not_selected', + }); + assert.match(text, /Page Data Orchestrator validation was not observed/); + assert.match(text, /建议(Page Data 审查服务不可用)/); + assert.match(text, /PAGE_DATA_VALIDATION_GATE_ENABLED=0|仅观察/); +}); + +test('formatPageDataValidationUserMessage keeps original message when code is unknown', () => { + const text = formatPageDataValidationUserMessage({ + code: 'SOME_UNKNOWN_CODE', + message: '原始错误', + }); + assert.equal(text, '原始错误'); +}); + +test('html issues map to remediable suggestion codes for goosed repair', () => { + assert.deepEqual( + suggestionCodesFromPageDataHtmlIssues([ + { relativePath: 'public/x.html', issue: 'forbidden_local_storage' }, + ]), + ['FORBIDDEN_LOCAL_STORAGE'], + ); + assert.equal(isPageDataSuggestionRemediable('DELIVERABLE_DATA_STORAGE_FORBIDDEN'), true); + assert.equal(isPageDataSuggestionRemediable('WORKFLOW_VALIDATION_GATE_UNAVAILABLE'), false); +}); + +test('buildPageDataValidationRepairPrompt includes Chinese suggestions for goosed', () => { + const prompt = buildPageDataValidationRepairPrompt({ + htmlIssues: [{ relativePath: 'public/x.html', issue: 'forbidden_local_storage' }], + unboundFiles: [{ relativePath: 'public/x.html' }], + }); + assert.match(prompt, /系统纠正建议/); + assert.match(prompt, /page-data-collect/); + assert.match(prompt, /检测到 localStorage 回退|FORBIDDEN_LOCAL_STORAGE/); + assert.match(prompt, /private_data_bind_workspace_page/); +}); diff --git a/scenarios/event-registration-page-data-repair.json b/scenarios/event-registration-page-data-repair.json new file mode 100644 index 0000000..fa78f90 --- /dev/null +++ b/scenarios/event-registration-page-data-repair.json @@ -0,0 +1,32 @@ +{ + "id": "event-registration-page-data-repair", + "name": "活动报名页修复(补列 + rebind)", + "description": "接续案例:HTML 已生成但 dataset 缺 participants/notes;让 Agent 补齐表列、重新注册并 bind,验证可提交", + "account": { + "username": "john4", + "password": "888888" + }, + "steps": [ + { + "action": "login", + "label": "登录 john4 账户" + }, + { + "action": "chat", + "label": "修复 event_registrations 缺列并重新 bind", + "message": "刚才活动报名页 public/event-register.html 和 public/event-register-admin.html 已经写好了,但 bind 失败:dataset「event_registrations」注册字段不存在:participants, notes。请立刻按 page-data-collect 修复:ALTER/补齐表列(含 participants、notes),重新 private_data_register_dataset,并对两个 public HTML 重新 private_data_bind_workspace_page(报名页 public insert,后台 password read,口令 88888888)。修完告诉我两个链接。不要用 localStorage。", + "selectedChatSkill": "page-data-collect", + "expect": { + "assistantMinChars": 60, + "timeoutMs": 600000, + "replyKeywords": ["报名", "后台"], + "forbidReplyPatterns": ["PLACEHOLDER_PAGE_ID", "survey-api"], + "survey": { + "requirePolicy": true, + "requireDataset": true, + "forbidHtmlPatterns": ["localStorage", "sessionStorage", "PLACEHOLDER_PAGE_ID"] + } + } + } + ] +} diff --git a/scenarios/event-registration-page-data.json b/scenarios/event-registration-page-data.json new file mode 100644 index 0000000..affc6a6 --- /dev/null +++ b/scenarios/event-registration-page-data.json @@ -0,0 +1,32 @@ +{ + "id": "event-registration-page-data", + "name": "活动报名页(Page Data · 表单+后台列表)", + "description": "案例:活动报名表单 + 口令后台报名列表;验证 page-data-collect 交付与 Page Data API,作为审查建议/自动修复链路的场景载体", + "account": { + "username": "john4", + "password": "888888" + }, + "steps": [ + { + "action": "login", + "label": "登录 john4 账户" + }, + { + "action": "chat", + "label": "创建活动报名页 + 后台列表", + "message": "帮我做一个活动报名页,使用 Page Data API:\n\n【报名表单页】访客填写:姓名、手机号、邮箱(选填)、参加人数、备注(选填),提交后保存。\n\n【后台报名列表页】密码 88888888,能查看全部报名记录,显示提交时间,支持导出。\n\n要求:用 page-data-client.js,禁止 localStorage;做完把报名页链接和后台链接发给我。直接创建并发布,不用再问我确认。", + "selectedChatSkill": "page-data-collect", + "expect": { + "assistantMinChars": 80, + "timeoutMs": 600000, + "replyKeywords": ["报名", "后台"], + "forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"], + "survey": { + "requirePolicy": true, + "requireDataset": true, + "forbidHtmlPatterns": ["localStorage", "sessionStorage", "onclick=", "PLACEHOLDER_PAGE_ID", "8899"] + } + } + } + ] +} diff --git a/scripts/case-study-page-data-suggestion-repair.mjs b/scripts/case-study-page-data-suggestion-repair.mjs new file mode 100644 index 0000000..14f056f --- /dev/null +++ b/scripts/case-study-page-data-suggestion-repair.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Case study: Page Data Tier A suggestions → goosed one-shot repair prompt. + * Deterministic (no live agent). Prints a structured report for the event-registration story. + */ +import { + buildPageDataValidationRepairPrompt, + formatPageDataValidationUserMessage, + isPageDataSuggestionRemediable, + resolvePageDataValidationSuggestions, +} from '../page-data-validation-suggestions.mjs'; +import { buildPageDataCollectRepairPrompt } from '../mindspace-page-data-finish-guard.mjs'; + +const CASES = [ + { + id: 'missing-deliverable', + title: '报名页未生成 public HTML', + code: 'PAGE_DATA_DELIVERABLE_MISSING', + message: 'Page Data 任务未生成可交付页面,不能标记成功', + }, + { + id: 'localstorage-forbidden', + title: '报名表误用 localStorage', + code: 'DELIVERABLE_DATA_STORAGE_FORBIDDEN', + message: '页面交付违反数据存储策略:forbidden_local_storage', + htmlIssues: [{ relativePath: 'public/event-registration-survey.html', issue: 'forbidden_local_storage' }], + }, + { + id: 'bind-failed', + title: '后台页未 bind / 策略缺失', + code: 'PAGE_DATA_DELIVERY_FAILED', + message: '页面数据绑定或发布失败', + unboundFiles: [{ relativePath: 'public/event-registration-admin.html' }], + }, + { + id: 'orchestrator-down', + title: '审查闸门不可用(不应自动修)', + code: 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE', + message: 'Page Data Orchestrator validation was not observed: not_selected', + }, +]; + +function section(title) { + console.log(`\n## ${title}`); +} + +const report = { + caseStudy: 'event-registration-page-data-suggestion-repair', + generatedAt: new Date().toISOString(), + cases: [], +}; + +section('Page Data 审查纠错案例(确定性)'); +console.log('场景:活动报名表单 + 口令后台列表'); +console.log('链路:失败 code → 中文建议(用户可见)→ remediable? → goosed repair prompt(每 run 最多 1 次)'); + +for (const item of CASES) { + const suggestions = resolvePageDataValidationSuggestions([item.code]); + const userVisible = formatPageDataValidationUserMessage({ + code: item.code, + message: item.message, + }); + const remediable = isPageDataSuggestionRemediable(item.code); + const goosedPrompt = remediable + ? buildPageDataValidationRepairPrompt({ + code: item.code, + message: item.message, + htmlIssues: item.htmlIssues ?? [], + unboundFiles: item.unboundFiles ?? [], + }) + : null; + const finishGuardPrompt = item.htmlIssues + ? buildPageDataCollectRepairPrompt({ + htmlIssues: item.htmlIssues, + unboundFiles: item.unboundFiles ?? [], + }) + : null; + + report.cases.push({ + id: item.id, + title: item.title, + code: item.code, + suggestionCount: suggestions.length, + remediable, + userVisiblePreview: userVisible.slice(0, 240), + goosedPromptPreview: goosedPrompt ? goosedPrompt.slice(0, 280) : null, + autoRepairToGoosed: remediable, + }); + + section(item.title); + console.log(`code: ${item.code}`); + console.log(`remediable → goosed: ${remediable ? 'YES (one-shot)' : 'NO'}`); + console.log('--- user-visible ---'); + console.log(userVisible); + if (goosedPrompt) { + console.log('--- goosed repair prompt (truncated) ---'); + console.log(goosedPrompt.split('\n').slice(0, 14).join('\n')); + console.log('...'); + } + if (finishGuardPrompt) { + console.log('finish-guard prompt includes suggestions:', /纠正建议|FORBIDDEN_LOCAL_STORAGE/.test(finishGuardPrompt)); + } +} + +section('汇总'); +console.table( + report.cases.map((row) => ({ + id: row.id, + code: row.code, + suggestions: row.suggestionCount, + auto_goosed: row.autoRepairToGoosed, + })), +); + +const remediableCount = report.cases.filter((row) => row.remediable).length; +const blockedCount = report.cases.length - remediableCount; +console.log(`\n结论: ${remediableCount} 个案例会自动把建议发给 goosed;${blockedCount} 个(基础设施/闸门)只展示建议不自动修。`); +console.log(JSON.stringify({ ok: true, remediableCount, blockedCount }, null, 2)); diff --git a/services/orchestrator/executor-adapters.mjs b/services/orchestrator/executor-adapters.mjs index 63479bb..2590b37 100644 --- a/services/orchestrator/executor-adapters.mjs +++ b/services/orchestrator/executor-adapters.mjs @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { spawn as nodeSpawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -89,14 +90,26 @@ async function* parseSse(body) { } } -function terminalGoosedEvent(event) { - const type = String( - event?.event - ?? event?.data?.type +function goosedEventType(event) { + // Real Goosed SSE frames use `event: message` (or omit event) and put the + // semantic type on `data.type` (e.g. Message / Finish). Prefer data.type so + // the generic SSE event name does not mask Finish. + const dataType = String( + event?.data?.type ?? event?.data?.event_type ?? '', ).trim().toLowerCase(); - return ['finish', 'finished', 'completed', 'error', 'failed'].includes(type); + if (dataType) return dataType; + return String(event?.event ?? '').trim().toLowerCase(); +} + +function terminalGoosedEvent(event) { + const type = goosedEventType(event); + if (['finish', 'finished', 'completed', 'error', 'failed'].includes(type)) { + return true; + } + // Some Goose builds emit `{ type: "Error", error: "..." }` without event name. + return Boolean(event?.data?.error) && type === ''; } export function createGoosedExecutorAdapter({ @@ -105,6 +118,8 @@ export function createGoosedExecutorAdapter({ fetchImpl = null, allowRemote = false, tlsInsecure = false, + provider = '', + model = '', workspaceResolver = async (reference) => reference?.id ?? null, } = {}) { const target = normalizeBaseUrl(baseUrl, { allowRemote }); @@ -166,6 +181,33 @@ export function createGoosedExecutorAdapter({ sessionRef: { kind: 'goosed-session', id: sessionId }, }); + const providerId = String(provider ?? '').trim(); + const modelId = String(model ?? '').trim(); + if (providerId) { + const providerRes = await transport(`${target}/agent/update_provider`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + session_id: sessionId, + provider: providerId, + ...(modelId ? { model: modelId } : {}), + }), + signal: controller.signal, + ...(dispatcher ? { dispatcher } : {}), + }); + if (!providerRes.ok) { + throw await responseError( + providerRes, + 'GOOSED_PROVIDER_UPDATE_FAILED', + 'Goosed provider update failed', + ); + } + await emit('executor_job_provider_set', { + provider: providerId, + model: modelId || null, + }); + } + const eventsResponse = await transport( `${target}/sessions/${encodeURIComponent(sessionId)}/events`, { @@ -188,9 +230,7 @@ export function createGoosedExecutorAdapter({ let finalEvent = null; for await (const event of parseSse(eventsResponse.body)) { eventCount += 1; - const eventType = String( - event?.event ?? event?.data?.type ?? event?.data?.event_type ?? 'message', - ).slice(0, 96); + const eventType = (goosedEventType(event) || 'message').slice(0, 96); await emit('executor_job_adapter_event', { adapter: 'goosed', eventType, @@ -204,17 +244,26 @@ export function createGoosedExecutorAdapter({ return { eventCount, finalEvent }; })(); + // Goosed /reply requires created + metadata.userVisible/agentVisible. + const userMessage = { + id: crypto.randomUUID(), + role: 'user', + created: Math.floor(Date.now() / 1000), + metadata: { + userVisible: true, + agentVisible: true, + }, + content: [{ type: 'text', text: request.task.instruction }], + }; const reply = await transport( `${target}/sessions/${encodeURIComponent(sessionId)}/reply`, { method: 'POST', headers: headers(), body: JSON.stringify({ - request_id: jobId, - user_message: { - role: 'user', - content: [{ type: 'text', text: request.task.instruction }], - }, + // Goosed requires a UUID request_id; Executor Job ids are not UUIDs. + request_id: crypto.randomUUID(), + user_message: userMessage, }), signal: controller.signal, ...(dispatcher ? { dispatcher } : {}), @@ -224,14 +273,14 @@ export function createGoosedExecutorAdapter({ reply.body?.cancel?.().catch?.(() => {}); const finished = await finishPromise; - const finalType = String( - finished.finalEvent?.event - ?? finished.finalEvent?.data?.type - ?? finished.finalEvent?.data?.event_type - ?? '', - ).toLowerCase(); + const finalType = goosedEventType(finished.finalEvent); if (['error', 'failed'].includes(finalType)) { - throw adapterError('GOOSED_RUN_FAILED', 'Goosed reported a failed terminal event'); + const detail = String( + finished.finalEvent?.data?.error + ?? finished.finalEvent?.data?.message + ?? 'Goosed reported a failed terminal event', + ).slice(0, 500); + throw adapterError('GOOSED_RUN_FAILED', detail); } return { outcome: 'completed', @@ -518,5 +567,6 @@ export const executorAdapterInternals = { normalizeBaseUrl, parseSse, resolveWorkspace, + goosedEventType, terminalGoosedEvent, }; diff --git a/services/orchestrator/executor-adapters.test.mjs b/services/orchestrator/executor-adapters.test.mjs index 6bbc782..f636207 100644 --- a/services/orchestrator/executor-adapters.test.mjs +++ b/services/orchestrator/executor-adapters.test.mjs @@ -44,12 +44,17 @@ test('Goosed adapter uses the current session API, streams bounded metadata and const adapter = createGoosedExecutorAdapter({ baseUrl: 'http://127.0.0.1:18006', secret: 'secret', + provider: 'custom_deepseek', + model: 'deepseek-v4-pro', workspaceResolver: async () => '/tmp/canary', fetchImpl: async (url, init) => { calls.push({ url, init }); if (url.endsWith('/agent/start')) { return Response.json({ id: 'session-1' }); } + if (url.endsWith('/agent/update_provider')) { + return Response.json({ ok: true }); + } if (url.endsWith('/events')) { return sseResponse([ { type: 'message', data: { type: 'message', secret: 'not-projected' } }, @@ -70,15 +75,27 @@ test('Goosed adapter uses the current session API, streams bounded metadata and assert.equal(calls[0].url, 'http://127.0.0.1:18006/agent/start'); assert.equal(calls[0].init.headers['X-Secret-Key'], 'secret'); assert.equal(JSON.parse(calls[0].init.body).working_dir, '/tmp/canary'); - assert.equal( - JSON.parse(calls.find((call) => call.url.endsWith('/reply')).init.body) - .user_message.content[0].text, - 'Fix the validation', + const providerBody = JSON.parse( + calls.find((call) => call.url.endsWith('/agent/update_provider')).init.body, ); + assert.deepEqual(providerBody, { + session_id: 'session-1', + provider: 'custom_deepseek', + model: 'deepseek-v4-pro', + }); + const replyBody = JSON.parse( + calls.find((call) => call.url.endsWith('/reply')).init.body, + ); + assert.equal(replyBody.user_message.content[0].text, 'Fix the validation'); + assert.equal(replyBody.user_message.role, 'user'); + assert.equal(typeof replyBody.user_message.created, 'number'); + assert.equal(replyBody.user_message.metadata.userVisible, true); + assert.equal(replyBody.user_message.metadata.agentVisible, true); assert.deepEqual( emitted.map((event) => event.type), [ 'executor_job_session_created', + 'executor_job_provider_set', 'executor_job_adapter_event', 'executor_job_adapter_event', ], @@ -87,6 +104,51 @@ test('Goosed adapter uses the current session API, streams bounded metadata and assert.deepEqual(await adapter.health(), { ok: true, status: 200 }); }); +test('Goosed adapter treats data.type Finish as terminal when SSE event is message', async () => { + // Real Goosed emits: event: message + data: {"type":"Finish",...} + const emitted = []; + const adapter = createGoosedExecutorAdapter({ + baseUrl: 'http://127.0.0.1:18006', + secret: 'secret', + workspaceResolver: async () => '/tmp/canary', + fetchImpl: async (url) => { + if (url.endsWith('/agent/start')) { + return Response.json({ id: 'session-finish' }); + } + if (url.endsWith('/events')) { + return sseResponse([ + { + type: 'message', + data: { + type: 'Message', + message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, + }, + }, + { + type: 'message', + data: { type: 'Finish', reason: 'stop', request_id: 'req-1' }, + }, + ]); + } + if (url.endsWith('/reply')) return Response.json({ ok: true }); + if (url.endsWith('/status')) return Response.json({ ok: true }); + throw new Error(`Unexpected URL: ${url}`); + }, + }); + const result = await adapter.submit(request(), { + jobId: 'job-finish', + emit: async (type, data) => emitted.push({ type, data }), + }); + assert.equal(result.outcome, 'completed'); + assert.equal(result.metrics.eventCount, 2); + assert.deepEqual( + emitted + .filter((event) => event.type === 'executor_job_adapter_event') + .map((event) => event.data.eventType), + ['message', 'finish'], + ); +}); + test('Goosed adapter rejects remote targets unless explicitly allowed', () => { assert.throws( () => createGoosedExecutorAdapter({ baseUrl: 'https://executor.example.com' }), diff --git a/services/orchestrator/worker.mjs b/services/orchestrator/worker.mjs index 7dd687a..db8401f 100644 --- a/services/orchestrator/worker.mjs +++ b/services/orchestrator/worker.mjs @@ -44,6 +44,8 @@ export function createConfiguredWorker({ env = process.env, fetchImpl = null } = ...(fetchImpl ? { fetchImpl } : {}), allowRemote: envFlag(env.MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED, false), tlsInsecure: envFlag(env.MEMIND_EXECUTOR_GOOSED_TLS_INSECURE, false), + provider: env.MEMIND_EXECUTOR_GOOSED_PROVIDER, + model: env.MEMIND_EXECUTOR_GOOSED_MODEL, workspaceResolver: async (reference) => workspaceAliases[reference?.id] ?? null, })); } diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index b503981..c981727 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1665,6 +1665,21 @@ export function createTkmindProxy({ } } + async function fetchSessionConversationForUser(userId, sessionId) { + if (!userId || !sessionId) return []; + const owns = await sessionStore.validateOwnership(userId, sessionId); + if (!owns) return []; + const target = await resolveTarget(sessionId); + const upstream = await apiFetch( + target, + apiSecret, + `/sessions/${encodeURIComponent(sessionId)}`, + ); + if (!upstream.ok) return []; + const session = await upstream.json().catch(() => null); + return Array.isArray(session?.conversation) ? session.conversation : []; + } + async function repairSessionToolHistory(sessionId) { if (!sessionId) return { changed: false, updated: false }; const target = await resolveTarget(sessionId); @@ -2638,6 +2653,7 @@ export function createTkmindProxy({ proxySessionEvents, resolveTarget, startSessionForUser, + fetchSessionConversationForUser, getRuntimeStatus, submitSessionReplyForUser, submitSessionReplyAndAwaitFinishForUser,