用户两项裁定落地(均为 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,产物待重构建)
178 lines
7.4 KiB
JavaScript
178 lines
7.4 KiB
JavaScript
#!/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('分支存在 ≠ 分支会对你的形状执行(守卫可能早退)。');
|