Files
MailUI4Agents/scripts/audit-v0-id-diffset.mjs
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

178 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 审计「手工要补哪些字段」的差集(**按真实形状实测**,不按分支标签枚举)。
*
* ## 为什么需要它
*
* 判断差集的方法(pi 提出、dsh 机械枚举过)是:
*
* 手工要补的 = 校验器要求 − 迁移器自愈
*
* 但「迁移器自愈」有两层,只读**分支标签**会算错:
*
* 1. `normalizeLegacyMessage()` 里有那个 `case`(分支存在)
* 2. 那个 `case` 的**守卫**对**你手上的形状**放行(分支真的会执行)
*
* 第 2 层才是决定性的。实测:`assistant/message`、`tool/result` 都有 heal 分支,
* 但它们的守卫是
*
* if (Object.hasOwn(data,"message") || …) return event; // 有 message 键 ⇒ 早退
*
* 而真实 v0 里这两个事件**都是嵌套 `message` 形状**(109 个文件里扁平旧形状出现 **0** 次)
* ⇒ 守卫必命中 ⇒ **不会 heal**。所以按分支标签算出来的差集是漏的。
*
* ## 本脚本的做法
*
* 不看代码、不看标签,直接对**真实 v0** 做消融实验:
* 把某个位置的消息剥成「无 id」形状,跑官方迁移链,看是否被拒绝。
* **拒绝 ⇒ 该位置在差集里**(校验要 id、迁移器不给)—— 这正是"要不要手工补"的定义。
*
* 对照组(脚本自己跑,缺一不可):
* - 不动任何东西 ⇒ 必须 OK(证明方法不误报)
* - `user/message` 剥空 ⇒ 必须 OK(证明确有自愈,不是"什么都拒绝")
*
* 用法:
* node scripts/audit-v0-id-diffset.mjs # 默认扫 /root/.dsh/sessions
* node scripts/audit-v0-id-diffset.mjs --root <dir>
*
* 退出码:0 = 扫描完成(**不**因发现差集成员而失败,那是正常结论);2 = 环境/参数问题。
*/
import { execFileSync } from 'node:child_process';
import { readdirSync, statSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const DS = process.env.DSH_INSTALL ?? '/usr/lib/node_modules/@deepseek-ai/dsh';
const { sessionFormatCatalog } = await import(
join(DS, 'node_modules/@deepseek-ai/dsh-session-format-catalog/lib/index.js')
);
const argv = process.argv.slice(2);
const argOf = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const ROOT = argOf('--root', '/root/.dsh/sessions');
const PROD = { recovery: 'recoverable', validation: 'transformed' };
const clone = (o) => structuredClone(o);
const readLines = (p) =>
execFileSync('zstd', ['-dc', p], { maxBuffer: 1 << 30 }).toString('utf8')
.split('\n').filter((l) => l.trim().length);
/** 生产档迁移:能过就是「无需手工补」。 */
function readable(header, events) {
try {
const r = sessionFormatCatalog.createRestore(clone(header), PROD);
for (const e of clone(events)) r.decodeRow(e);
r.finish();
return true;
} catch { return false; }
}
/**
* 每个「message 承载位置」:匹配函数 + 消融函数(把该位置剥成无 id/role 形状)。
* 位置按**真实 v0 形状**给,不是按代码分支名。
*/
const SITES = {
'agent/inbox/spliced :: inserted[]': {
match: (e) => e.type === 'agent/inbox/spliced' && e.data.inserted?.[0] && 'id' in e.data.inserted[0],
ablate: (d) => { delete d.inserted[0].id; delete d.inserted[0].role; },
},
'session/title-llm-request :: messages[]': {
match: (e) => e.type === 'session/title-llm-request' && e.data.messages?.[0] && 'id' in e.data.messages[0],
ablate: (d) => { delete d.messages[0].id; delete d.messages[0].role; },
},
'assistant/message :: data.message': {
match: (e) => e.type === 'assistant/message' && e.data.message && 'id' in e.data.message,
ablate: (d) => { delete d.message.id; delete d.message.role; },
},
'tool/result :: data.message': {
match: (e) => e.type === 'tool/result' && e.data.message && 'id' in e.data.message,
ablate: (d) => { delete d.message.id; delete d.message.role; },
},
'user/message :: data': {
match: (e) => e.type === 'user/message',
ablate: (d) => { delete d.id; delete d.role; },
},
};
/** 递归收集 <store>/<id>/session.jsonl.zstd */
function* walk(dir) {
for (const n of readdirSync(dir)) {
const p = join(dir, n);
if (statSync(p).isDirectory()) yield* walk(p);
else if (n === 'session.jsonl.zstd') yield p;
}
}
const stat = {};
for (const k of Object.keys(SITES)) stat[k] = { tested: 0, refused: 0 };
let files = 0, skipped = 0, ctrlNoAb = 0, ctrlUserHealed = 0;
for (const path of walk(ROOT)) {
let lines, header;
try { lines = readLines(path); header = JSON.parse(lines[0]); } catch { skipped++; continue; }
if (header.version !== 0) { skipped++; continue; }
files++;
let events = lines.slice(1).map((l) => JSON.parse(l));
// 基线:先补掉 spliced(否则基线就不可读,测不出别的位置)
let n = 0;
events = events.map((e) => {
if (e.type !== 'agent/inbox/spliced') return e;
const inserted = (e.data.inserted ?? []).map((m) => {
if ('id' in m && 'role' in m) return m;
n++; return { id: `baseline-${e.seq}-${n}`, role: 'user', ...m };
});
return { ...e, data: { ...e.data, inserted } };
});
if (!readable(header, events)) { skipped++; continue; }
ctrlNoAb++; // 对照一:不动任何东西 ⇒ 可读
// 对照二:user/message 剥空必须仍可读(证明确有自愈)
{
const i = events.findIndex(SITES['user/message :: data'].match);
if (i >= 0) {
const e2 = clone(events);
SITES['user/message :: data'].ablate(e2[i].data);
if (readable(header, e2)) ctrlUserHealed++;
}
}
for (const [label, { match, ablate }] of Object.entries(SITES)) {
const i = events.findIndex(match);
if (i < 0) continue;
const e2 = clone(events);
ablate(e2[i].data);
stat[label].tested++;
if (!readable(header, e2)) stat[label].refused++;
}
}
console.log(`root: ${ROOT}`);
console.log(`v0 文件: ${files}(跳过 ${skipped}:解压失败/非 v0/基线不可读)\n`);
console.log('=== 对照组(方法有效性)===');
console.log(` 不动任何东西 ⇒ 可读的基线: ${ctrlNoAb}`);
console.log(` user/message 剥 id+role 后仍可读(= 确有自愈): ${ctrlUserHealed}`);
if (ctrlNoAb === 0) { console.error('\n[FAIL] 没有可读基线,结论无效'); process.exit(2); }
if (ctrlUserHealed === 0) { console.error('\n[FAIL] 自愈对照未成立,方法可能有问题'); process.exit(2); }
console.log('\n=== 逐位置消融:剥掉 id(+role) 后是否被拒绝 ===');
console.log(' 位置'.padEnd(42), '样本 拒绝 判定');
const members = [];
for (const [label, v] of Object.entries(stat)) {
let verdict;
if (v.tested === 0) verdict = '(无样本)';
else if (v.refused === v.tested) { verdict = '★ 差集成员(要手工补)'; members.push(label); }
else if (v.refused === 0) verdict = '自愈侧(无需补)';
else verdict = `混合(${v.refused}/${v.tested})—— 需按形状细分`;
console.log(' ' + label.padEnd(40), String(v.tested).padStart(4), String(v.refused).padStart(6), ' ' + verdict);
}
console.log(`\n=== 差集(校验要 id、迁移器不给 ⇒ 必须手工补)共 ${members.length} 个位置 ===`);
for (const m of members) console.log(' • ' + m);
console.log('\n注意:这是**按真实形状实测**的结果,比按代码分支标签枚举更可靠 ——');
console.log('分支存在 ≠ 分支会对你的形状执行(守卫可能早退)。');