@migor/cli 0.10.1 → 0.10.3

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.
package/dist/report.js CHANGED
@@ -8,6 +8,11 @@
8
8
  * - 裸 Trace(含 `spans`)—— 如 `createOtlpExporter` 之外的本地落盘、或自己 writeFile 的记录;
9
9
  * - TaskRecord(含 `result.trace` 或 `trace`)—— 如 `FileTaskStore` / `SqliteTaskStore` 的导出。
10
10
  *
11
+ * ⚠️ TaskRecord 导出是 **append-only**(FileTaskStore 每次 save 追加一行):审批流里同一棵
12
+ * 挂起段 trace 会被写多次(挂起 save / approve 落库 / 接管再 save,各行的 `result.trace`
13
+ * 是同一份)⇒ 按行计会把 runs / 成功率 / 成本全部翻倍。因此解析后按 **`traceId` 去重,
14
+ * 同一 traceId 只留最后一行**(append-only 里后面的是新状态),能力表与会话表共用这份输入。
15
+ *
11
16
  * 聚合实现**不在这里** —— 复用 `@migor/trace-view` 的 `summarizeTrace`
12
17
  * (构建期由 scripts/copy-assets.mjs 拷进 dist/inspector/)。CLI 因此保持零 npm 运行时依赖,
13
18
  * 且与 inspector 面板/官网 playground 共用同一份聚合口径(避免两处漂移)。
@@ -29,6 +34,51 @@ export function extractTrace(v) {
29
34
  return trace;
30
35
  return null;
31
36
  }
37
+ /** TaskRecord 的任务键(`FileTaskStore` / `SqliteTaskStore` 导出的顶层 `taskId`)。裸 Trace 没有它。 */
38
+ function taskKeyOf(v) {
39
+ if (!v || typeof v !== 'object')
40
+ return null;
41
+ const id = v.taskId;
42
+ return typeof id === 'string' && id.length > 0 ? id : null;
43
+ }
44
+ /**
45
+ * run 根 span —— 优先 `kind === 'run'`,跑挂的 trace 没有就退到第一个 span。
46
+ * 与 `inspector-routes.ts` 的 `summarize` 同款取法(两处都在读 run 根的 attribute,口径要一致)。
47
+ */
48
+ function rootSpanOf(t) {
49
+ const spans = (t.spans ?? []);
50
+ return spans.find((s) => s?.kind === 'run') ?? spans[0] ?? null;
51
+ }
52
+ /** 「没有会话」桶的名字 —— 常量而非字面量:断言与渲染必须指同一个东西 */
53
+ export const NO_SESSION = '(无会话)';
54
+ /** 分组键:会话优先,其次任务,最后落「无会话」桶 */
55
+ function groupKeyOf(t, taskKey) {
56
+ const attrs = rootSpanOf(t)?.attributes;
57
+ const sid = attrs?.['session.id'];
58
+ if (typeof sid === 'string' && sid.length > 0)
59
+ return sid;
60
+ return taskKey ?? NO_SESSION;
61
+ }
62
+ /** 单条 run 的根 span 耗时(`endedAt - startedAt`);读不出计 0 */
63
+ function runMs(t) {
64
+ const root = rootSpanOf(t);
65
+ const s = Number(root?.startedAt);
66
+ const e = Number(root?.endedAt);
67
+ return Number.isFinite(s) && Number.isFinite(e) && e >= s ? e - s : 0;
68
+ }
69
+ /**
70
+ * 单条 run 的成本(美元)。
71
+ *
72
+ * ⚠️ **刻意不从 `summarizeTrace` 的能力行反加**:能力行只覆盖进了 capability 的
73
+ * `llm.turn`,run 根直属的回合(主 agent 自己问的)不在任何能力行里 ⇒ 反加会**漏**。
74
+ * `Trace.totalUsage` 才是权威口径(各 `llm.turn` 之和,刻意不含 capability 聚合以免
75
+ * 重复计数,`src/core/trace.ts`)。读不出(手工写的 trace / 未定价模型)⇒ null
76
+ * (渲染成 `-`),**不伪装成 0**。
77
+ */
78
+ function runCostUsd(t) {
79
+ const c = t.totalUsage?.costEstimate;
80
+ return typeof c === 'number' && Number.isFinite(c) ? c : null;
81
+ }
32
82
  /** 打印一张按总耗时降序的能力排行(跨多行记录时按能力合并) */
33
83
  /** 用法串(cli.ts 的子命令 `--help` 也从这里取,避免两处各写一份) */
34
84
  export const USAGE = '用法:agentia report <trace.jsonl> [--json]';
@@ -50,16 +100,21 @@ export async function reportCommand(args) {
50
100
  catch (e) {
51
101
  throw new Error(`读不到文件 ${file}(${e.message})`);
52
102
  }
53
- const traces = [];
103
+ // 解析时同时留下**外层记录**的任务键(TaskRecord 的 `taskId`)—— 它与 trace 本体不同源
104
+ // (在 wrapper 上,不在 `spans` 里),故在解析处一并取出。
105
+ // ⚠️ 「一行 = 一条 run」对 append-only 导出不成立(同一棵 trace 可能占多行),
106
+ // 去重在解析**之后**统一做(见下),别在循环里顺手去重 —— 坏行计数会被带歪。
107
+ const entries = [];
54
108
  let badLines = 0;
55
109
  for (const line of raw.split('\n')) {
56
110
  const s = line.trim();
57
111
  if (!s)
58
112
  continue;
59
113
  try {
60
- const t = extractTrace(JSON.parse(s));
114
+ const v = JSON.parse(s);
115
+ const t = extractTrace(v);
61
116
  if (t)
62
- traces.push(t);
117
+ entries.push({ trace: t, taskKey: taskKeyOf(v) });
63
118
  else
64
119
  badLines += 1;
65
120
  }
@@ -67,10 +122,30 @@ export async function reportCommand(args) {
67
122
  badLines += 1;
68
123
  }
69
124
  }
70
- if (traces.length === 0) {
125
+ if (entries.length === 0) {
71
126
  throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
72
127
  (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
73
128
  }
129
+ // **按 traceId 去重,同一 traceId 只留最后一行**。FileTaskStore 是 append-only:审批流里
130
+ // 同一棵挂起段 trace 会被写多次(挂起 save / approve 落库 / 接管再 save),各行的
131
+ // `result.trace` 是同一份 —— 不去重则 runs / 成功率 / 成本按行数翻倍。
132
+ // - 取**最后**一行:append-only 里后面的是新状态(接管后的记录才是终态);
133
+ // - 键是 traceId 而不是 taskId:一个任务可以跨**多段** run(挂起 → 恢复 → 再挂起),
134
+ // 每段一棵新 trace(traceId == runId),按 taskId 去重会把不同段错并成一条;
135
+ // - 无 traceId 的行(手写 trace)无从判同,全部保留;
136
+ // - 过滤保序:留下的是各 traceId 在文件里**最后一次出现的位置**,输出顺序与直觉一致。
137
+ // 能力表、runs 总计、会话表**共用**这份去重后的输入(两处各算各的必然漂移)。
138
+ const lastByTraceId = new Map();
139
+ entries.forEach((e, i) => {
140
+ const id = e.trace.traceId;
141
+ if (typeof id === 'string' && id.length > 0)
142
+ lastByTraceId.set(id, i);
143
+ });
144
+ const deduped = entries.filter((e, i) => {
145
+ const id = e.trace.traceId;
146
+ return typeof id !== 'string' || id.length === 0 || lastByTraceId.get(id) === i;
147
+ });
148
+ const traces = deduped.map((e) => e.trace);
74
149
  // 聚合口径与 inspector 面板 / 官网 playground 同源(构建期拷进来的 trace-view 产物)
75
150
  // 这份聚合实现由 packages/cli/scripts/copy-assets.mjs 在构建期从 trace-view 产物拷进来。
76
151
  // 缺它只可能是「装坏了」或「直接跑未构建的源码」—— 原始 ERR_MODULE_NOT_FOUND 里全是
@@ -105,6 +180,42 @@ export async function reportCommand(args) {
105
180
  }
106
181
  }
107
182
  const rows = [...merged.values()].sort((a, b) => b.totalMs - a.totalMs || b.calls - a.calls);
183
+ // G6:会话 / 任务维度 —— 与上面的能力表**互补**:能力表看单个能力,这里看一整段会话/任务
184
+ // (多轮 run 的合计 + 成功率)。成功率**必须带分母**:只报百分比在小组上是噪声。
185
+ const byGroup = new Map();
186
+ for (const e of deduped) {
187
+ const key = groupKeyOf(e.trace, e.taskKey);
188
+ let row = byGroup.get(key);
189
+ if (!row) {
190
+ row = {
191
+ session: key,
192
+ runs: 0,
193
+ succeeded: 0,
194
+ failed: 0,
195
+ totalMs: 0,
196
+ costUsd: null,
197
+ costPerSuccess: null,
198
+ };
199
+ byGroup.set(key, row);
200
+ }
201
+ row.runs += 1;
202
+ // 成败判据与上面的 `okRuns` **同一条**(`status !== 'error'`)—— 两处各写一份会漂移。
203
+ if (e.trace.status !== 'error')
204
+ row.succeeded += 1;
205
+ else
206
+ row.failed += 1;
207
+ row.totalMs += runMs(e.trace);
208
+ const c = runCostUsd(e.trace);
209
+ if (c != null)
210
+ row.costUsd = (row.costUsd ?? 0) + c;
211
+ }
212
+ for (const row of byGroup.values()) {
213
+ // ⚠️ 成功数为 0 ⇒ null(渲染成 `-`)。写成 0 是把「除不了」伪装成「每个成功任务不花钱」,
214
+ // 正好说反 —— 这正是本项要防的那笔假账。
215
+ row.costPerSuccess =
216
+ row.costUsd != null && row.succeeded > 0 ? row.costUsd / row.succeeded : null;
217
+ }
218
+ const sessions = [...byGroup.values()].sort((a, b) => b.totalMs - a.totalMs || b.runs - a.runs || a.session.localeCompare(b.session));
108
219
  const failRows = traces
109
220
  .filter((t) => t.status === 'error')
110
221
  .map((t) => {
@@ -124,12 +235,25 @@ export async function reportCommand(args) {
124
235
  skippedLines: badLines,
125
236
  failures: failRows,
126
237
  capabilities: rows,
238
+ sessions,
127
239
  totals: { totalMs, errors: errTotal, capabilities: rows.length },
128
240
  };
129
241
  console.log(JSON.stringify(payload, null, 2));
130
242
  return 0;
131
243
  }
132
- const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
244
+ // 按**显示宽度**补齐:`(无会话)` 里的汉字占 2 列而 `String.length` 只算 1 ⇒ 用字符数补齐
245
+ // 会把那一行整体右推 3 格。而它偏偏是「宿主没开会话」时的**唯一**一行,歪得很显眼。
246
+ // 判据是启发式(≥ U+2E80 记 2 列):覆盖 CJK 与全角标点;`…`(U+2026)等仍是 1 列。
247
+ const dispWidth = (s) => {
248
+ let w = 0;
249
+ for (const ch of s)
250
+ w += (ch.codePointAt(0) ?? 0) >= 0x2e80 ? 2 : 1;
251
+ return w;
252
+ };
253
+ const pad = (s, n) => {
254
+ const w = dispWidth(s);
255
+ return w >= n ? s : s + ' '.repeat(n - w);
256
+ };
133
257
  const fmtMs = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`);
134
258
  console.log(`trace 文件 ${file}`);
135
259
  console.log(`runs ${traces.length}(失败 ${traces.length - okRuns})${badLines > 0 ? ` · 跳过无法解析 ${badLines} 行` : ''}`);
@@ -141,6 +265,18 @@ export async function reportCommand(args) {
141
265
  if (failRows.length > 3)
142
266
  console.log(` …另有 ${failRows.length - 3} 条失败 run`);
143
267
  console.log('');
268
+ // G6 区块:**无论有没有会话都打**。没配 `session: { store, id }` 时只会有一行
269
+ // `(无会话)` —— 那是**如实呈现**(说明宿主没开会话),不是缺陷(见设计稿 §6)。
270
+ console.log(`${pad('session', 34)} ${pad('runs', 6)} ${pad('ok', 5)} ${pad('fail', 6)} ${pad('total', 9)} ${pad('cost', 12)} ${pad('cost/ok', 12)}`);
271
+ for (const r of sessions) {
272
+ console.log(`${pad(r.session, 34)} ${pad(String(r.runs), 6)} ${pad(String(r.succeeded), 5)} ` +
273
+ `${pad(String(r.failed), 6)} ${pad(fmtMs(r.totalMs), 9)} ` +
274
+ `${pad(r.costUsd != null ? r.costUsd.toFixed(6) : '-', 12)} ` +
275
+ // `costPerSuccess` 为 null 有两种来路(没成本 / 没成功),两者都只能给 `-` ——
276
+ // 区别在 ok 列上写着(ok = 0 时就是后者)。
277
+ `${pad(r.costPerSuccess != null ? r.costPerSuccess.toFixed(6) : '-', 12)}`);
278
+ }
279
+ console.log('');
144
280
  if (rows.length === 0) {
145
281
  console.log('(没有可归因的能力:这些 trace 里既没有 capability span,也没有 tool.output 事件)');
146
282
  return 0;
@@ -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.1"
14
+ "@migor/agentia": "^0.10.3"
13
15
  },
14
16
  "devDependencies": {
15
- "@migor/cli": "^0.10.1",
17
+ "@migor/cli": "^0.10.3",
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);
@@ -7,6 +7,8 @@
7
7
  "ES2022"
8
8
  ],
9
9
  "strict": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "forceConsistentCasingInFileNames": true,
10
12
  "rootDir": "src",
11
13
  "outDir": "dist",
12
14
  "types": [
package/dist/templates.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * 字符串不过编译器:模板代码对仓库自己的 typecheck/lint 不可见,缺陷要等用户
5
5
  * npm install 后才暴露(discover 路径「生产必崩」就是这么漏出去的)。真文件化后
6
6
  * 模板被仓库自己的工具链全程照看:`tsconfig.templates.json` 把它纳入 tsc
7
- * ('@migor/agentia' 经 paths 映射到框架 src),Biome 照常收 lint/format。
7
+ * ('@migor/agentia' 经 paths 映射到框架 src);Biome 收 lint,formatter 对 templates/** 关闭(生成物字节是兼容契约,见 biome.jsonc)。
8
8
  *
9
9
  * 占位符纪律:token 只许出现在**字符串 / 注释 / 标识符**位置
10
10
  * (`__PROJECT_NAME__` / `__NAME__` / `__CLASS_NAME__` / `__METHOD_NAME__`),
@@ -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.1",
3
+ "version": "0.10.3",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {