@migor/cli 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,52 @@ import { readdirSync, statSync } from 'node:fs';
25
25
  import { dirname, join, resolve } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { DEFAULT_BUDGET, } from './dev-protocol.js';
28
+ // 并排 A/B:复用 `diff.ts` 那份**去类型移植副本**(CLI 零运行时依赖、不能 import 框架)
29
+ // ⇒ 面板看到的差异与 `agentia diff a.jsonl b.jsonl` 逐字相同。
30
+ // 可比性判据放 panel-logic(不是 diff.ts):它判的是**面板四个输入**的差异,
31
+ // 与 span 配对算法无关;放一起会让两个不同的关注点混在一个文件里。
32
+ //
33
+ // ⚠️ 严格 Trace 类型用 `Parameters<typeof diffTraces>[0]` 取,**不从 diff.ts 导出它**:
34
+ // 那边的 `interface Trace` 是「逐字同形移植」的**内部**声明,导出它等于给这份副本开
35
+ // 公共面(旁人会开始 import 它,于是改算法时它也成了要同步的第二个副本 —— 正是
36
+ // 逐字对拍要防的那件事)。用 Parameters 取,签名改了这里自动跟着变,零漂。
37
+ import { diffTraces } from './diff.js';
38
+ import { comparability } from './panel-logic.js';
39
+ /**
40
+ * 宽松 `TraceLike` → `diffTraces` 能吃的形状,**补上缺省的空容器**。
41
+ *
42
+ * ⚠️ **为什么不直接强转**(`ta as unknown as StrictTrace`,本仓的坏习惯):
43
+ * `diffTraces` 内部有 `pushAttrDiffs` / `pushEventDiffs`,两者都**直接** `Object.keys(x)` /
44
+ * `x.length`,参数为 `undefined` 时抛 `Cannot convert undefined or null to object`。
45
+ * 而 `validateTrace`(入站校验)**只**要求 `spanId` / `name` / `startedAt` ——
46
+ * `attributes` 与 `events` 不在必填之列 ⇒ 一条裁剪过的 trace 能合法进 `/ingest`,
47
+ * 于是 compare 路由会500。
48
+ *
49
+ * ⚠️ **为什么不改 `diff.ts`**:它是 `src/engine/trace-diff.ts` 的**逐字同形移植副本**
50
+ * (注释与 `structure.test.mjs` 都声明「改算法必须两边同步」)⇒ 在CLI 侧单方面加固会让
51
+ * 两侧**分叉**,而逐字对拍守护(`test/diff.test.mjs`)会当场红。那条路的正解是**框架侧
52
+ * 一起改**(`attributes ?? {}` / `events ?? []`),属`src/` 语义变更,要走 spec §10 ——
53
+ * **不在本稿范围**。这里在**消费侧**归一化,是零核心改动的正解。
54
+ *
55
+ * 归一化只补「缺省空容器」,**不改任何真值**:span 树、usage、rootSpanId、以及
56
+ * **已存在的 events 一律原样保留**(⚠️ 把 `events` 清空是静默失真 —— 那样「事件序列
57
+ * 差异」永远测不出来,而 diff 看起来仍然「正常」,比抛错更坏)。
58
+ */
59
+ function normalizeForDiff(t) {
60
+ return {
61
+ ...t,
62
+ spans: (t.spans ?? []).map((s) => ({
63
+ ...s,
64
+ attributes: s.attributes ?? {},
65
+ // ⚠️ `TraceLike`(面板的宽松投影)**漏了`events` 字段** —— 运行时它是有的
66
+ //(`POST /ingest` 收到的是裸 `Trace`),但类型上访问不到 ⇒ 只能按索引读。
67
+ // `Array.isArray` 守卫是必要的:缺失时补 `[]`,**存在时原样保留**。
68
+ events: Array.isArray(s.events)
69
+ ? s.events
70
+ : [],
71
+ })),
72
+ };
73
+ }
28
74
  /** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
29
75
  export const TOKEN_COOKIE = 'agentia_dev_token';
30
76
  /** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
@@ -294,6 +340,55 @@ function handleRunDetail(ctx) {
294
340
  }
295
341
  ctx.json(ctx.res, 200, t);
296
342
  }
343
+ /**
344
+ * 并排 A/B:`GET /api/runs/compare?a=<id>&b=<id>` → `{ diff, comparability }`。
345
+ *
346
+ * **为什么在服务端算diff,不在浏览器里算**:`diffTraces` 是框架的纯函数,而 CLI 侧
347
+ * 已有一份**去类型移植副本**(`diff.ts`;CLI 零运行时依赖、不能 import 框架,注释
348
+ * 声明「改算法必须两边同步」、逐字对拍守护在 `test/diff.test.mjs`)。复用那一份
349
+ * 才能保证「面板里看到的差异」与「`agentia diff a.jsonl b.jsonl` 得到的差异」**逐字
350
+ * 相同** —— 另写一份就是本仓反复踩的「两处口径会漂」。
351
+ *
352
+ * ⚠️ **可比性判据一起返回**(档 B §3.3):`diffTraces` 不知道面板四个输入里哪些变了,
353
+ * 跨了能力选择(app 级、重启进程)的两条 run 比出来的差异多半与用户改的那句话无关。
354
+ * 服务端算完一起给,面板**必须**把它显示出来 —— 静默给一份没意义的 diff 比不给更糟。
355
+ */
356
+ function handleRunCompare(ctx) {
357
+ const a = ctx.url.searchParams.get('a');
358
+ const b = ctx.url.searchParams.get('b');
359
+ if (!a || !b) {
360
+ ctx.json(ctx.res, 400, { error: '缺少 a 或 b 参数(各给一个 traceId)' });
361
+ return;
362
+ }
363
+ if (a === b) {
364
+ // 不是「无害的重复」:同一条跟自己比恒等于无差异,面板会显示一份「没有差异」的
365
+ // 结论,而用户以为自己选了两条不同的 run。
366
+ ctx.json(ctx.res, 400, { error: 'a 与 b 是同一条 run —— 选两条不同的才能比出差异' });
367
+ return;
368
+ }
369
+ const { runs, notes } = ctx.state;
370
+ // ⚠️ 两个 `get` 都要,**且都要用 `!` 显式收窄** —— 只查一次(`const ta = ...; if (!ta) 404`)
371
+ // 之后 TS 不会把「另一个也非空」这件事带过 `return`(Map.get 的返回是 `| undefined`,
372
+ // 与局部变量的控制流分析无关)。写成 `!ta || !tb` 才既短又能让 TS 收窄。
373
+ const ta = runs.get(a);
374
+ const tb = runs.get(b);
375
+ if (!ta || !tb) {
376
+ const missing = ta ? b : a;
377
+ ctx.json(ctx.res, 404, { error: `run 不存在(可能已被淘汰):${missing}` });
378
+ return;
379
+ }
380
+ const noteA = notes.get(a) ?? null;
381
+ const noteB = notes.get(b) ?? null;
382
+ // 形状转换与 `diff.ts` 的 `readFirstTrace`(`:356`)**同款思路**(宽松投影 → 严格源类型),
383
+ // 但**多一步 `normalizeForDiff`** —— 直接强转会在裁剪过的 trace 上 500(那里讲清了原因)。
384
+ ctx.json(ctx.res, 200, {
385
+ diff: diffTraces(normalizeForDiff(ta), normalizeForDiff(tb)),
386
+ comparability: comparability(noteA, noteB),
387
+ // note 一起回:面板要显示「这两条当时用了什么能力 / 在哪个目录」,
388
+ // 否则一个「不可比」的结论没有上下文(用户不知道为什么这次比对不可信)。
389
+ notes: { a: noteA, b: noteB },
390
+ });
391
+ }
297
392
  function handleDevState(ctx) {
298
393
  ctx.json(ctx.res, 200, ctx.dev ? ctx.dev.state() : unavailableDevState());
299
394
  }
@@ -490,7 +585,11 @@ async function handleStatic(ctx) {
490
585
  * 的实现抛出,如 `pickFolder` 的 409/501)会**穿过本函数**冒泡给调用方的 catch,
491
586
  * 由那里统一映射成状态码。
492
587
  *
493
- * ⚠️ 顺序即优先级:`/api/runs` 必须排在 `/api/runs/` 之前(后者是前缀匹配)。
588
+ * ⚠️ 顺序即优先级:`/api/runs` 必须排在 `/api/runs/` 之前(后者是前缀匹配),
589
+ * **而 `/api/runs/compare` 必须排在 `/api/runs/` 之前** —— 它是那条前缀的**子路径**,
590
+ * 放后面会被 `handleRunDetail` 先吃掉,而那个 handler 会把 `compare?a=…&b=…`
591
+ * 整段当成 traceId 去查表 ⇒ 恒 404,且报错信息是「run 不存在」,完全指错方向。
592
+ * (`ctx.path` 是 `url.pathname`、**不含** query,所以前缀匹配按 `/api/runs/compare` 判。)
494
593
  */
495
594
  export async function handleRoutes(ctx) {
496
595
  const { req, path } = ctx;
@@ -500,6 +599,10 @@ export async function handleRoutes(ctx) {
500
599
  return handleIngestEvent(ctx);
501
600
  if (req.method === 'GET' && path === '/api/runs')
502
601
  return handleListRuns(ctx);
602
+ // ⚠️ 顺序咬人:`compare` **必须**在下一行之前 —— 它是 `/api/runs/` 的子路径,
603
+ // 放后面会被 `handleRunDetail` 先吃掉(traceId 变成 "compare" ⇒ 恒 404 且报错指错方向)。
604
+ if (req.method === 'GET' && path === '/api/runs/compare')
605
+ return handleRunCompare(ctx);
503
606
  if (req.method === 'GET' && path.startsWith('/api/runs/'))
504
607
  return handleRunDetail(ctx);
505
608
  if (req.method === 'GET' && path === '/api/dev')
@@ -241,6 +241,158 @@ export function rememberNote(notes, traceId, note, max) {
241
241
  notes.delete(oldest);
242
242
  }
243
243
  }
244
+ // ---------- 并排 A/B 的可比性判据(档 B / plans/2026-10-05-dev-workbench.md §3.3) ----------
245
+ /** 对比选择态的**上限**:并排就是并排两块,第三块没有版面(也读不过来) */
246
+ export const COMPARE_MAX = 2;
247
+ /**
248
+ * 点 run 条目时**对比选择**该怎么变(⌘/Ctrl + 单击;普通单击仍是「打开」)。
249
+ *
250
+ * 三条口径:
251
+ * - **再点已选中的那条 ⇒ 取消它**(而不是「凑不满两块就不动」)—— 用户要能改主意;
252
+ * - **超过两块 ⇒ 挤掉最早选的那条**(FIFO):不静默丢弃后来的选择;
253
+ * - **单选状态下重复点同一条** ⇒ 原样返回(幂等,不产生 `[a, a]`)。
254
+ *
255
+ * 为什么不设「未满两块时点了就退出对比」:那会让「我想退出对比」需要额外的按钮
256
+ * (面板已经有「打开」这个天然出口了 —— 普通单击就回到单run 视图)。
257
+ */
258
+ export function toggleCompare(selected, id) {
259
+ if (selected.includes(id))
260
+ return selected.filter((x) => x !== id);
261
+ if (selected.length < COMPARE_MAX)
262
+ return [...selected, id];
263
+ return [selected[1], id];
264
+ }
265
+ /**
266
+ * 「全量」的归一化键:空/null/undefined **都是**全量(与 `normalizeToolSources` 同口径)。
267
+ *
268
+ * ⚠️ **分隔符用 `'\\n'`,不是 `'\u0000'`** —— 别照抄 `dev-logic.sameToolSources` 的 NUL:
269
+ * 它比的是**拼接后的字符串**(NUL 不可能出现在能力名里,安全),而这里若把**字面 NUL
270
+ * 字符**打进 `.ts` 源文件,整个文件会被 `file`/grep 判成 **binary**(实测踩过:
271
+ * `grep` 报「Binary file matches」、`tsc` 照过,但源码审查工具与 diff 全废)。
272
+ * 能力名走 `^[A-Za-z0-9_-]{1,64}$`(框架装配期校验),换行同样不可能出现。
273
+ */
274
+ function capsKey(toolSources) {
275
+ if (toolSources === null || toolSources === undefined || toolSources.length === 0)
276
+ return '*';
277
+ return [...toolSources].sort().join('\n');
278
+ }
279
+ export function compareView(compare) {
280
+ if (compare.length === 0)
281
+ return 'single';
282
+ return compare.length < COMPARE_MAX ? 'picking' : 'dual';
283
+ }
284
+ /**
285
+ * 新 run 收尾后**自动**把它补进对比(把「改 prompt → 重跑 → 对照」这一跳补上)。
286
+ *
287
+ * 为什么要有:档 B 落地时那条承诺是「改一句 prompt 就能**立刻重跑对照**」,可配对要用户
288
+ * 手动 ⌘ 点两条 —— 三步操作不是「立刻」。这个判定把中间那一步吞掉。
289
+ *
290
+ * 为什么**只在 picking 态**(已选一条、还没选第二条)触发:
291
+ * - 空选择 ⇒ 用户还没表达对比意图,自动切双栏是**替用户决定**(会被读成面板在乱跳);
292
+ * - 已选两条 ⇒ 用户已经选好了,再塞一条等于**挤掉**他刚选的那条;
293
+ * - dev 环是**单飞**的 ⇒ picking 态下新收尾的那条必然是用户刚跑的那条,而他在 picking
294
+ * 态下的意图几乎必然是「拿它跟刚跑的这条比」⇒ 误伤概率极低。
295
+ *
296
+ * ⚠️ **不静默**:触发时面板播一条 notice(调用侧负责),所以「为什么屏幕变双栏了」用户
297
+ * 立刻知道;退出路径 = 普通单击任意一条(那条路径有单测,且本批刚补过「必须走
298
+ * `exitCompare()`」的守卫 W4)。
299
+ *
300
+ * ⚠️ **不动单飞**:这是「事后回看」的自动化,不是「同时跑」—— 真并发要改
301
+ * `shouldDeferRestart` 那条单源化判据(设计稿 §8 的「不做」)。
302
+ *
303
+ * @returns 新的选择数组;`null` = 不该自动配对(调用侧保持原样,别自己改成 `[]`)
304
+ */
305
+ export function autoPairTarget(selected, incomingId) {
306
+ if (selected.length !== 1)
307
+ return null;
308
+ const base = selected[0];
309
+ if (base === incomingId)
310
+ return null;
311
+ return [base, incomingId];
312
+ }
313
+ /**
314
+ * 一条差异该怎么显示(`renderDiff` 里的分支,抽出来以便单测)。
315
+ *
316
+ * 为什么值得抽:`sp.missing` 那条判错的后果是**静默指错方向** —— 缺侧记录会显示成
317
+ * 「B侧少一个能力」,而真相是 A 侧独有。`diff-a` / `diff-b` 是**展示用**的标签
318
+ * (`diffTraces` 的 `missing` 真值是 `'a' | 'b'`,由 `packages/cli/src/diff.ts` 定)。
319
+ */
320
+ export function diffSideLabel(missing) {
321
+ return missing === 'a' ? 'A' : 'B';
322
+ }
323
+ /**
324
+ * 差异值的一行文本(`null` / `undefined` ⇒「(无)」)。
325
+ *
326
+ * ⚠️ **不能用 `??` 把缺省折成空串或 0**:那会让「字段不存在」显示成「值是 0/空」——
327
+ * 本仓反复踩的那类假读数(读数与「读不出」在业务上可分时,不许拿缺省值糊过去)。
328
+ */
329
+ export function diffValueText(v) {
330
+ if (v === null || v === undefined)
331
+ return '(无)';
332
+ if (typeof v === 'string')
333
+ return v;
334
+ try {
335
+ return JSON.stringify(v) ?? String(v);
336
+ }
337
+ catch {
338
+ return String(v);
339
+ }
340
+ }
341
+ /**
342
+ * compare 端点**失败**时的那句提示(档 B)。
343
+ *
344
+ * ⚠️ **不能用 `body.error || res.status`**:`||` 把「服务端返回了空串 / 没有 error 字段」
345
+ * 一律折成状态码 —— 那是本仓反复踩的「读不出伪装成读数」同族(`?? 0` 那条纪律的字符串版)。
346
+ * 口径:`error` 是**非空字符串**才用它,否则回落 HTTP 状态码(那个一定是真的)。
347
+ * ⚠️ 也不要把「读body 失败」折成空对象就完事 —— 那样显示的是「读差异失败:500」而真相是
348
+ * 「响应不是 JSON」(网关插了页 / token 过期返回 HTML)。
349
+ */
350
+ export function diffFetchErrorText(status, body, parseFailed) {
351
+ if (parseFailed)
352
+ return `读差异失败:响应不是合法 JSON(HTTP ${status})`;
353
+ const e = body?.error;
354
+ return `读差异失败:${typeof e === 'string' && e.length > 0 ? e : `HTTP ${status}`}`;
355
+ }
356
+ /**
357
+ * 两条 run 的差异**能不能归因到「我改了什么」**。
358
+ *
359
+ * ⚠️ **这是本档最容易做错的地方**:`diffTraces` 比的是 span 路径与字段,它**不知道**
360
+ * 面板的四个输入里哪些变了。能力选择与工作目录是 **app 级**的(换它们要重启进程,
361
+ * `RunRequest` 的注释已写明)⇒ 两条跨了能力选择的 run,`runConfigSnapshot` 整个变了,
362
+ * diff 出来的差异**大部分与「我改的那句话」无关**。此时静默给一份 diff = 让用户
363
+ * 误读(本仓最忌讳的那类假绿)。所以口径是**显式分三档**:
364
+ *
365
+ * - `comparable` —— 同能力 + 同工作目录 ⇒ 差异可归因(**推荐路径**:只改 prompt / 多轮);
366
+ * - `workdir-differs` —— 能力同、目录不同 ⇒ 可比,但摘要必须显示目录不同,
367
+ * 否则「差在哪」会被读成「模型变了」;
368
+ * - `capabilities-differ` —— 能力选择不同(**跨了进程重启**)⇒ 提示比对意义有限。
369
+ *
370
+ * 判据用 `capsKey` 归一化:`null`(`RunNote` 的全量)、`undefined`(面板侧
371
+ * `normalizeToolSources` 的全量)、`[]`(被它归一化成全量的那个陷阱值)**三者同义**。
372
+ * 直接比数组会把「全量 vs 全量」判成不同 ⇒ 每次正常 A/B 都飘一条假警告。
373
+ */
374
+ export function comparability(a, b) {
375
+ // note 缺失(trace 落盘后重启面板、或 note 先于 trace 被淘汰)⇒ **不猜**,按不可比处理
376
+ if (a === null || b === null) {
377
+ return {
378
+ level: 'capabilities-differ',
379
+ message: '这两条 run 里有一条缺少开发期元信息(能力选择 / 工作目录不可知),比对意义有限',
380
+ };
381
+ }
382
+ if (capsKey(a.toolSources) !== capsKey(b.toolSources)) {
383
+ return {
384
+ level: 'capabilities-differ',
385
+ message: '这两条 run 的能力选择不同(跨了进程重启),比对意义有限 —— 建议只改 prompt 后重跑',
386
+ };
387
+ }
388
+ if (a.workdir !== b.workdir) {
389
+ return {
390
+ level: 'workdir-differs',
391
+ message: `注意:这两条 run 的工作目录不同(${a.workdir} vs ${b.workdir})`,
392
+ };
393
+ }
394
+ return { level: 'comparable', message: '' };
395
+ }
244
396
  // ---------- run 收尾的反馈语 ----------
245
397
  /**
246
398
  * `run-done` → 面板底部那句操作反馈。
@@ -9,10 +9,10 @@
9
9
  "typecheck": "tsc --noEmit -p tsconfig.json"
10
10
  },
11
11
  "dependencies": {
12
- "@migor/agentia": "^0.9.5"
12
+ "@migor/agentia": "^0.10.1"
13
13
  },
14
14
  "devDependencies": {
15
- "@migor/cli": "^0.9.5",
15
+ "@migor/cli": "^0.10.1",
16
16
  "tsx": "^4.19.0",
17
17
  "typescript": "^7.0.2",
18
18
  "@types/node": "^22.0.0"
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.9.5",
3
+ "version": "0.10.1",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "agentia": "./dist/cli.js"
8
8
  },
9
+ "exports": {
10
+ "./package.json": "./package.json"
11
+ },
9
12
  "files": [
10
13
  "dist",
11
14
  "CHANGELOG.md"