@migor/cli 0.10.0 → 0.10.2

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` → 面板底部那句操作反馈。
@@ -25,7 +25,8 @@
25
25
  | 文件 | 作用 |
26
26
  |---|---|
27
27
  | `src/app.ts` | **装配**:导出 `createAgentApp({ toolSources?, workdir? })` 工厂 + `CAPABILITY_DIRS` + `createSessionStore()`;`.env` 也在这里读 |
28
- | `src/main.ts` | **启动**:薄入口 —— 调工厂 → `app.run(...)` → 处理 `result.error` |
28
+ | `src/main.ts` | **默认入口 = 服务**:调同一个工厂 → `createHttpHandler` + `AsyncRunner` + `SqliteTaskStore`(崩溃续跑)→ drain 优雅停机。`npm start` 跑的就是它 |
29
+ | `src/batch.ts` | **一次性入口(Job)**:薄入口 —— 调工厂 → `app.run(...)` → 处理 `result.error`。`npm run start:batch` 跑的是它 |
29
30
  | `src/dev.config.ts` | 开发期**数据**声明(`multiTurn` / `budget` / `workdir`);只有 `agentia dev` 读它 |
30
31
  | `src/session-store.ts` | 多轮的会话后端(`FileSessionStore`,落盘 `.agentia/session.json`) |
31
32
  | `src/registry.ts` | 显式注册表(`agentia g` 自动维护) |
@@ -59,13 +60,56 @@ ANTHROPIC_API_KEY=sk-ant-...
59
60
  npm run dev -- "你的问题"
60
61
  ```
61
62
 
62
- ## 构建与生产运行
63
+ ## 构建与运行
63
64
 
64
65
  ```bash
65
66
  npm run build # tsc → dist/ + .md 文本资产跟随拷贝(asset() 按文件位置解析,必须跟着 .js 走)
66
- npm start -- "你的问题" # 跑编译产物 dist/main.js(部署/Docker 用这条)
67
+ npm start # 起 HTTP 服务(= node dist/main.js)
67
68
  ```
68
69
 
70
+ **`npm start` 起来的就是一个在监听的服务**(NestJS 的 `main.ts` 那条口径):build 完 run 起来
71
+ 就是能用的东西,不是跑一次就退出。`npm run start:prod` 与它同物,是显式的生产名。
72
+
73
+ 端点:
74
+
75
+ - `GET /healthz` —— 健康检查(探针用,不鉴权)
76
+ - `POST /run` —— 同步 run(请求带 `Accept: text/event-stream` → SSE 逐帧)
77
+ - `POST /tasks` —— 异步任务(轮询 `GET /tasks/:id`,或 SSE 订阅 `GET /tasks/:id/stream`)
78
+
79
+ 服务侧给到的另外三件事:任务落 SQLite(`AGENTIA_DB` 指定路径,缺省**工程根**下的
80
+ `./agentia.db` —— 不是 cwd 相对,换工作目录启动不会静默换库)、**崩溃续跑**(重启后上次
81
+ 未完成的任务接着跑)、**优雅停机**(SIGTERM/SIGINT → 拒新单、等在飞收尾、15s 超时收口;
82
+ 超时没跑完的任务下次启动续跑)。
83
+
84
+ **鉴权**:设了环境变量 `AGENTIA_TOKEN` 就启用 Bearer 校验(`Authorization: Bearer <token>`,
85
+ `/healthz` 除外);**不设则无任何鉴权**,启动时会打一条响亮警告 —— 此时只应监听
86
+ 回环 / 内网(或放到有鉴权的反代后面)。框架**不实现** token/JWT 策略,更复杂的
87
+ 鉴权自己改 `src/main.ts` 里的 `authenticate` 钩子。
88
+
89
+ 环境变量:`PORT`(缺省 3000)· `AGENTIA_DB`(缺省工程根下的 `./agentia.db`)· `AGENTIA_TOKEN`(可选)。
90
+
91
+ ⚠️ **Node ≥ 22.5**:任务存储 `SqliteTaskStore` 用 `node:sqlite`,更低版本起服务时**构造期**
92
+ 会抛可读报错。想让这个服务跑在 Node 18 上:把 `src/main.ts` 里那行换成 `FileTaskStore`
93
+ (`@migor/agentia` 同样导出、同一套 API,代价是单写者前提)。`npm run dev` 与
94
+ `npm run start:batch` 不碰它,它们的 Node 下限仍是 18。
95
+
96
+ ## 一次性任务(Job 形态)
97
+
98
+ cron / CI / 容器里「跑一次就退出」的那种,用 `batch.ts`:
99
+
100
+ ```bash
101
+ npm run build && npm run start:batch -- "你的问题" # = node dist/batch.js <问题>
102
+ ```
103
+
104
+ 它没有端口、没有 `/healthz`、没有停机流程 —— 只跑一轮、打印结果、退出(失败置非零退出码)。
105
+
106
+ ## 更完整的生产配方
107
+
108
+ 要 metrics(`/metrics`)/ OTLP 导出 / Dockerfile / compose 编排,直接抄框架仓库的
109
+ [`examples/deploy/`](https://github.com/retrychx/agentia/tree/main/examples/deploy)
110
+ 与 `docs/deployment.md` —— `src/main.ts` 是它的精简版,两者同一套 API;上线前的决定清单
111
+ 也在那份 `docs/deployment.md` 里。API 细节见本项目 `AGENTS.md` 的「触发与宿主」节。
112
+
69
113
  也可以用环境变量(适合 CI / 容器)——**真实环境变量优先,不会被 `.env` 覆盖**:
70
114
 
71
115
  ```bash
@@ -4,3 +4,9 @@ ANTHROPIC_API_KEY=
4
4
  # 可选
5
5
  # ANTHROPIC_BASE_URL=https://api.anthropic.com
6
6
  # AGENTIA_MODEL=claude-opus-5
7
+
8
+ # 只给服务入口(src/main.ts)用的三个 —— 不写就是缺省值:
9
+ # 3000 端口 / 工程根下的 agentia.db / 无鉴权
10
+ # PORT=3000
11
+ # AGENTIA_DB=./agentia.db
12
+ # AGENTIA_TOKEN=换成一串随机值才启用 Bearer 鉴权
@@ -7,3 +7,6 @@ dist
7
7
 
8
8
  # dev 环的本地状态:对话历史(.agentia/session.json)等 —— 是本机调试产物,不进版本库
9
9
  .agentia/
10
+
11
+ # 默认服务入口(src/main.ts)的 SQLite 任务库(wal/shm 是它的伴生文件)
12
+ agentia.db*
@@ -6,13 +6,15 @@
6
6
  "dev": "agentia dev",
7
7
  "build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
8
8
  "start": "node dist/main.js",
9
+ "start:prod": "node dist/main.js",
10
+ "start:batch": "node dist/batch.js",
9
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
10
12
  },
11
13
  "dependencies": {
12
- "@migor/agentia": "^0.10.0"
14
+ "@migor/agentia": "^0.10.2"
13
15
  },
14
16
  "devDependencies": {
15
- "@migor/cli": "^0.10.0",
17
+ "@migor/cli": "^0.10.2",
16
18
  "tsx": "^4.19.0",
17
19
  "typescript": "^7.0.2",
18
20
  "@types/node": "^22.0.0"
@@ -30,9 +30,9 @@ import ReadFileTool from './tools/read-file/index.js';
30
30
  * 读哪个文件、什么时候读由这里决定;已存在的真实环境变量优先,不会被文件覆盖。
31
31
  * 想换路径/顺序:`loadEnvFile({ path: '.env.local' })`,或直接删掉这一行改用自己的加载器。
32
32
  *
33
- * ⚠️ 必须在 **app.ts(装配模块)**,不能在 main.ts:`agentia dev` 的 runner 只 import
34
- * 本文件、**从不执行 main.ts**。放在 main.ts 里会让 `npm run dev` 静默读不到 `.env`,
35
- * 而 `npm start` 读得到 —— 同一份 `.env` 两个行为,且失败的形状是「忘了配 key」。
33
+ * ⚠️ 必须在 **app.ts(装配模块)**,不能在任一个入口(`main.ts` / `batch.ts`):`agentia dev`
34
+ * 的 runner 只 import 本文件、**从不执行入口**。放在入口里会让 `npm run dev` 静默读不到
35
+ * `.env`,而 `npm start` 读得到 —— 同一份 `.env` 两个行为,且失败的形状是「忘了配 key」。
36
36
  */
37
37
  loadEnvFile();
38
38
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 一次性批处理入口 —— `npm run start:batch -- "你的问题"`(= `node dist/batch.js`)。
3
+ * 跑一次、打印 `finalText`、退出:cron / CI / 容器里的 **Job 形态**。
4
+ *
5
+ * ⚠️ 默认入口不是本文件,是 `main.ts`(`npm start` = 长期在线的 HTTP 服务)。
6
+ * 两者只是**跑法**不同,装配共用 app.ts 的 `createAgentApp()`。
7
+ */
8
+ import { createAgentApp } from './app.js';
9
+
10
+ // 装配在 app.ts 里(与启动分离):`agentia dev` 的调试环要复用同一个工厂,
11
+ // 才能把「能力选择 / 工作目录」喂进 createApp。别把 createApp(...) 搬回这里。
12
+ // `.env` 也由 app.ts 读 —— 那才是 `agentia dev` 与两个入口(`main.ts` / 本文件)
13
+ // **都**会经过的那条路(本文件在 dev 环下根本不执行),写在这里会让三者行为不一致。
14
+ const app = await createAgentApp();
15
+
16
+ const { result } = await app.run([{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }]);
17
+
18
+ // 注意:run 失败**不会抛**(硬失败被记进 result.error 与 trace 后正常返回)—— 不显式检查就会
19
+ // 「打印一行空白 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
20
+ if (result.error) {
21
+ console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
22
+ console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(src/app.ts 的 loadEnvFile() 会读)或 export 均可;');
23
+ console.error(' 换端点 / 注入自定义 client 见项目内 AGENTS.md。');
24
+ process.exitCode = 1;
25
+ }
26
+ if (result.finalText) console.log(result.finalText);
@@ -1,19 +1,106 @@
1
- import { createAgentApp } from './app.js';
1
+ /**
2
+ * 入口(**默认**)—— HTTP 服务:`npm run build && npm start` 起来的就是它,在 `PORT`
3
+ * (缺省 3000)上长期监听。这就是 NestJS 那条口径里 `main.ts` 的角色:build 完 run 起来
4
+ * 就是一个**能用的服务**,不是跑一次就退出。
5
+ *
6
+ * 一次性批处理在 `batch.ts`(`npm run start:batch -- "你的问题"`)。两者**共用** app.ts 的
7
+ * 同一个 `createAgentApp()` 工厂 —— 能力选择 / 工作目录 / `.env` 读取只有一份实现。
8
+ *
9
+ * 本文件给到的:`/healthz`、同步 `POST /run`(含 SSE 逐帧)、异步 `POST /tasks` +
10
+ * 崩溃续跑(SqliteTaskStore + resumePending)、优雅停机(SIGTERM/SIGINT → drain →
11
+ * server.close)、可选 Bearer 鉴权(AGENTIA_TOKEN)。
12
+ * 更完整的生产配方(metrics / OTLP / Dockerfile / compose / 反代)在框架仓库的
13
+ * examples/deploy/ 与 docs/deployment.md;API 细节见本项目 AGENTS.md 的「触发与宿主」节。
14
+ *
15
+ * ⚠️ SqliteTaskStore 用 `node:sqlite`,需要 **Node ≥ 22.5** —— 更低版本在**构造期**抛可读
16
+ * 报错(框架对它延迟加载,所以 `npm run dev` 与 `npm run start:batch` 不受影响)。
17
+ * 想让这个服务在 Node 18 上也能起:把下面那行换成 `FileTaskStore`(`@migor/agentia` 同样
18
+ * 导出,构造参数一样是文件路径)—— 代价是**单写者前提**,多进程共写同一文件会交错。
19
+ */
20
+ import { mkdirSync } from 'node:fs';
21
+ import { createServer } from 'node:http';
22
+ import type { IncomingMessage } from 'node:http';
23
+ import { dirname, resolve } from 'node:path';
24
+ import { AsyncRunner, SqliteTaskStore, createHttpHandler } from '@migor/agentia';
25
+ import { createAgentApp, PROJECT_ROOT } from './app.js';
2
26
 
3
- // 装配在 app.ts 里(与启动分离):`agentia dev` 的调试环要复用同一个工厂,
4
- // 才能把「能力选择 / 工作目录」喂进 createApp。别把 createApp(...) 搬回这里。
5
- // `.env` 也由 app.ts 读 —— 那才是 `agentia dev` 与 `npm start` **都**会经过的那条路
6
- // (本文件在 dev 环下根本不执行),写在这里会让两个入口的行为不一致。
27
+ // `PORT=`(空串)不是「用缺省」的写法,但 `Number('')` 恰好是 0 ⇒ 静默变成**随机端口**
28
+ // (没人知道的端口 = 看起来就是起不来)。这里把空串显式当缺省,并对非整数 / 越界响亮报错。
29
+ // ⚠️ 别改成 `Number(process.env.PORT) || 3000` —— 那会把 `PORT=0`(由 OS 分配端口,
30
+ // 冒烟测试正是靠它拿随机端口)一起吃掉。
31
+ const rawPort = process.env.PORT;
32
+ const PORT = rawPort === undefined || rawPort.trim() === '' ? 3000 : Number(rawPort);
33
+ if (!Number.isInteger(PORT) || PORT < 0 || PORT > 65535) {
34
+ throw new Error(
35
+ `PORT 必须是 0–65535 的整数(0 = 由操作系统分配),实际:${JSON.stringify(rawPort)}`,
36
+ );
37
+ }
38
+
39
+ // 缺省落**工程根**、不是 cwd:服务形态常换工作目录启动(systemd / 容器 `-w`),cwd 相对会让
40
+ // 同一个工程在两种启动方式下用**两个库** —— 而 `resumePending()` 静默找不到上一个进程的任务,
41
+ // 表面看像「任务丢了」。与 app.ts 的能力目录同一条判据、同一个常量(PROJECT_ROOT)。
42
+ const DB_PATH = process.env.AGENTIA_DB ?? resolve(PROJECT_ROOT, 'agentia.db');
43
+ const TOKEN = process.env.AGENTIA_TOKEN;
44
+
45
+ if (DB_PATH !== ':memory:') mkdirSync(dirname(DB_PATH), { recursive: true });
46
+
47
+ // 不设 token 不静默:响亮警告,但不强制(与框架「鉴权只是缝」同档 —— 策略是宿主的事)
48
+ if (!TOKEN) {
49
+ console.warn('[server] 警告:未设 AGENTIA_TOKEN,HTTP 面无任何鉴权,仅应监听回环/内网');
50
+ }
51
+
52
+ // 装配与 main.ts / dev 环共用同一个工厂 —— 别把 createApp(...) 搬来这里旁路它,
53
+ // 否则「能力选择 / 工作目录 / .env 读取」在生产与开发两条路上长出两个行为。
7
54
  const app = await createAgentApp();
8
55
 
9
- const { result } = await app.run([{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }]);
56
+ // 耐久任务存储(WAL,多进程安全)+ 崩溃续跑:上次未完成的 queued/running 任务
57
+ // 重启后接着跑(不是丢弃);多进程共库时按 ownerId 跳过本进程记录
58
+ const store = new SqliteTaskStore(DB_PATH);
59
+ const runner = new AsyncRunner(app, { store });
60
+ const resumed = await runner.resumePending();
61
+ if (resumed) console.log(`[server] 续跑 ${resumed} 个未完成任务`);
62
+
63
+ // 设了 AGENTIA_TOKEN 才挂鉴权钩子(Bearer 校验);框架的 authenticate 是缝,
64
+ // 抛错即 401(细节只进服务端日志),正常返回即通过。/healthz 不在鉴权面内。
65
+ const authenticate = TOKEN
66
+ ? (req: IncomingMessage): void => {
67
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) {
68
+ throw new Error('缺少或错误的 Bearer token');
69
+ }
70
+ }
71
+ : undefined;
72
+ const handler = createHttpHandler(app, {
73
+ runner,
74
+ ...(authenticate ? { authenticate } : {}),
75
+ });
76
+
77
+ const server = createServer(handler);
78
+
79
+ server.listen(PORT, () => {
80
+ // PORT=0 时端口由操作系统分配,以 server.address() 拿到的实际端口为准
81
+ const addr = server.address();
82
+ const actualPort = typeof addr === 'object' && addr !== null ? addr.port : PORT;
83
+ // ⚠️ 首行是**就绪信号**(e2e 从它解析实际端口判定服务可用)——
84
+ // 改文案要同步改仓库 scripts/e2e-cli.ts 的正则
85
+ console.log(`[server] listening on :${actualPort}(db=${DB_PATH})`);
86
+ console.log(' POST /run 同步 run(带 Accept: text/event-stream → SSE 逐帧)');
87
+ console.log(' POST /tasks 异步任务(轮询 GET /tasks/:id)');
88
+ console.log(' GET /healthz 健康检查(探针用,不鉴权)');
89
+ });
10
90
 
11
- // 注意:run 失败**不会抛**(硬失败被记进 result.error 与 trace 后正常返回)—— 不显式检查就会
12
- // 「打印一行空白 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
13
- if (result.error) {
14
- console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
15
- console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(src/app.ts 的 loadEnvFile() 会读)或 export 均可;');
16
- console.error(' 换端点 / 注入自定义 client 见项目内 AGENTS.md。');
17
- process.exitCode = 1;
91
+ // 优雅停机:框架**不订阅信号**,这是宿主的职责。第二次信号直接退出(便于强杀)
92
+ let stopping = false;
93
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
94
+ process.on(sig, () => {
95
+ if (stopping) return;
96
+ stopping = true;
97
+ console.log(`[server] ${sig}:优雅停机……`);
98
+ void (async () => {
99
+ // 拒新单 → 等在飞同步 run 与异步任务收尾 → 超时强制收口 SSE
100
+ const clean = await handler.drain({ timeoutMs: 15_000 });
101
+ await new Promise<void>((resolve) => server.close(() => resolve()));
102
+ console.log(clean ? '[server] 已排空退出' : '[server] 超时收口,剩余任务下次启动续跑');
103
+ process.exit(0);
104
+ })();
105
+ });
18
106
  }
19
- if (result.finalText) console.log(result.finalText);
package/dist/templates.js CHANGED
@@ -96,6 +96,9 @@ function capabilityVars(name) {
96
96
  * - `@migor/cli` 装进 devDependencies(而不是让用户每次 npx 去 registry 拉):工程内
97
97
  * `npx agentia …` 走本地 bin —— 离线可用,且版本被 pin 住与框架同批。
98
98
  * 这两条 pin(框架 + CLI)是**版本发布面**(scripts/release-surface.mjs),格式不能动。
99
+ * - `start` / `start:prod` **同物**(都是 `node dist/main.js`)是刻意的:本脚手架的 dev 路径
100
+ * 是 `npm run dev`,所以 `start` 已经是「跑 dist 的那条」——`start:prod` 只是把
101
+ * 「这是生产入口」显式写出来(NestJS 用户会敲这个名字)。多一行 script,不是第二条路。
99
102
  */
100
103
  export function projectPackageJson(name) {
101
104
  return renderTemplate('package.json', { __PROJECT_NAME__: name });
@@ -112,7 +115,7 @@ export function projectTsconfig() {
112
115
  return renderTemplate('tsconfig.json');
113
116
  }
114
117
  /**
115
- * 装配入口 app.ts(templates/src/app.ts)—— **与启动分离**的那个工厂。
118
+ * 装配模块 app.ts(templates/src/app.ts)—— **与启动分离**的那个工厂。
116
119
  *
117
120
  * 为什么单独一个文件(D9(d)):dev 环要驱动用户的 app,就得有个「接受选项的工厂」
118
121
  * 能喂进能力选择与工作目录。`CAPABILITY_DIRS` 也在它里面,且**同时是面板能力选择器的
@@ -154,18 +157,35 @@ export function readFileToolIndexTs() {
154
157
  return renderTemplate('src/tools/read-file/index.ts');
155
158
  }
156
159
  /**
157
- * 入口 main.ts(templates/src/main.ts)。**薄入口**:调工厂 → 处理 result。
160
+ * 默认入口 main.ts(templates/src/main.ts)—— **HTTP 服务**:`npm run build && npm start`
161
+ * 起来的就是它,在 `PORT` 上长期监听(NestJS 的 `main.ts` 那条口径)。
162
+ * `createHttpHandler` + `AsyncRunner` + `SqliteTaskStore`(崩溃续跑)+ drain 优雅停机 +
163
+ * 可选 Bearer 鉴权(AGENTIA_TOKEN)。
158
164
  *
159
- * 装配搬去了 app.ts(见上),这里只留「跑一次」。两件事的顺序与语义都不能变 ——
160
- * 尤其 `result.error` 那段:run 失败**不抛**,不检查就是「打印空行 + 退出 0」,
161
- * 让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
162
- *
163
- * ⚠️ `loadEnvFile()` **不在**这里,在 app.ts —— dev 环只 import app.ts、从不执行本文件,
164
- * 把读 .env 留在这边会让 `npm run dev` 与 `npm start` 对同一份 `.env` 有两个行为。
165
+ * **无条件生成、而且是默认**:脚手架的交付承诺是「build 产物 run 起来就是个能用的服务」。
166
+ * 服务化不该让用户自己去 examples/deploy 抄,也不该让用户先翻 README 才知道该起哪个入口。
167
+ * ⚠️ 与 batch.ts 同一道闸:必须复用 app.ts 的 `createAgentApp()` 工厂,
168
+ * 不许旁路 createApp(templates.test.mjs 钉着)。
165
169
  */
166
170
  export function mainTs(name) {
167
171
  return renderTemplate('src/main.ts', { __PROJECT_NAME__: name });
168
172
  }
173
+ /**
174
+ * 一次性入口 batch.ts(templates/src/batch.ts)。**薄入口**:调工厂 → 处理 result。
175
+ *
176
+ * 「跑一次就退出」的 Job 形态(cron / CI / 容器一次性任务)。它此前叫 `main.ts` ——
177
+ * 2026-10-09 把默认入口翻成服务时挪到这个名字,好让 `main.ts` 就是 `npm start` 跑的那个
178
+ * (见 docs/plans/2026-10-09-scaffold-default-service.md)。
179
+ *
180
+ * 两件事的顺序与语义都不能变 —— 尤其 `result.error` 那段:run 失败**不抛**,
181
+ * 不检查就是「打印空行 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
182
+ *
183
+ * ⚠️ `loadEnvFile()` **不在**这里,在 app.ts —— dev 环只 import app.ts、从不执行入口,
184
+ * 把读 .env 留在这边会让 `npm run dev` 与 `npm start` 对同一份 `.env` 有两个行为。
185
+ */
186
+ export function batchTs(name) {
187
+ return renderTemplate('src/batch.ts', { __PROJECT_NAME__: name });
188
+ }
169
189
  export function projectReadme(name) {
170
190
  return renderTemplate('README.md', { __PROJECT_NAME__: name });
171
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {