Просмотр исходного кода

fix: CLI 的 ESM require 坑 + 补平台形态云函数 + 部署指南实测补充

- fix(cli): ES 模块里 require('os') → import { hostname } from 'node:os'(Node>=20 报 ERR_AMBIGUOUS_MODULE_SYNTAX)
- add(cloud): deliverable-report-v2.platform.js —— 平台 handler(request,response) 形态(原稿是 Parse.Cloud.define,平台跑不起来)
- fix(cloud): user/company 是 Parse 指针,owner 比较取 id 兼容两种存储
- docs: 关键 ID 核查 / 技能只读路径 / 改 Function 记录即生效
fmode-ops 20 часов назад
Родитель
Сommit
251a137eb5
4 измененных файлов с 219 добавлено и 2 удалено
  1. 2 1
      bin/skill-deliverable.mjs
  2. 145 0
      cloud/deliverable-report-v2.platform.js
  3. 11 0
      docs/CHANGELOG.md
  4. 61 1
      docs/deployment-guide.md

+ 2 - 1
bin/skill-deliverable.mjs

@@ -8,6 +8,7 @@
  *   skill-deliverable init         # 初始化凭据
  */
 import { resolveAgentId, resolveSessionToken, report, list } from '../lib/index.mjs';
+import { hostname } from 'node:os';
 
 const [cmd, ...args] = process.argv.slice(2);
 
@@ -19,7 +20,7 @@ switch (cmd) {
       agentId: agentId || '(缺失)',
       sessionToken: token ? token.slice(0, 12) + '...' : '(缺失)',
       status: (agentId && token) ? 'ready' : 'incomplete',
-      hostname: require('os').hostname(),
+      hostname: hostname(),
     }, null, 2));
     break;
   }

+ 145 - 0
cloud/deliverable-report-v2.platform.js

@@ -0,0 +1,145 @@
+// fmodeagent-deliverables v0.0.2 — 智能体任务成果上报与聚合 (表: AgentDeliverable)
+// 调用: POST /api/functions {token, id:<Function objectId>, params:{action:'report'|'list'|'byProject', ...}}
+// v0.0.2 变更:
+//   1. report 移除 FMODE_AGENT_SUPERADMIN 检查 → 仅校验「该 agent 的 owner」;agent 无 owner 时首次上报自动绑定
+//   2. agent 不存在时自动注册(自注册,agentId 有效即可)
+//   3. agentId / agentObjectId 任一可传,自动补全,并写入 agent 指针
+//   4. list/byProject 可见性保持 v0.0.1 逻辑(超管全量 | 有公司看本公司 | 无公司看自己)
+var _sessionToken = function () {
+  if (params && params.token) return params.token;
+  if (request.body && request.body.token) return request.body.token;
+  return null;
+};
+var _getUser = async function (t) {
+  if (!t) return null;
+  if (request.user) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session); q.equalTo('sessionToken', t); q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) { return null; }
+};
+var _isSuper = async function (user) {
+  try {
+    var q = new Parse.Query(Parse.Role); q.equalTo('name', 'FMODE_AGENT_SUPERADMIN'); q.equalTo('users', user);
+    return (await q.first({ useMasterKey: true })) != null;
+  } catch (e) { return false; }
+};
+var _idOf = function (v) {
+  if (!v) return null;
+  if (typeof v === 'string') return v;
+  return v.id || null;
+};
+var UPDATABLE = ['agentId','title','summary','project','projectName','artifacts','tags','deliveredAt','status','extra'];
+function handler(request, response) {
+  (async function () {
+    try {
+      var p = params || {};
+      var user = await _getUser(_sessionToken());
+      if (!user) return _respond(response, 401, { code: 401, msg: 'SessionToken 无效' });
+      var act = p.action || 'list';
+      var superAd = await _isSuper(user);
+      var uCompany = user.get('company') || null;
+
+      if (act === 'report') {
+        if (!p.title) return _respond(response, 400, { code: 400, msg: 'title required' });
+        if (!p.agentId && !p.agentObjectId) return _respond(response, 400, { code: 400, msg: 'agentId/agentObjectId required' });
+
+        // ---- 解析 agent:先按 objectId,再按 agentId;都没有则自注册 ----
+        var agent = null;
+        if (p.agentObjectId) {
+          var q0 = new Parse.Query('FmodeAgent'); q0.equalTo('objectId', p.agentObjectId);
+          agent = await q0.first({ useMasterKey: true });
+        }
+        if (!agent && p.agentId) {
+          var q1 = new Parse.Query('FmodeAgent'); q1.equalTo('agentId', p.agentId);
+          agent = await q1.first({ useMasterKey: true });
+        }
+        if (!agent) {
+          if (!p.agentId) return _respond(response, 400, { code: 400, msg: '新 agent 注册需要 agentId' });
+          agent = new Parse.Object('FmodeAgent');
+          agent.set('agentId', p.agentId);
+          agent.set('user', user);
+          agent.set('status', 'online');
+          agent.set('heartbeatAt', new Date());
+          agent = await agent.save(null, { useMasterKey: true });
+        }
+
+        // ---- 鉴权:仅该 agent 的 owner(v0.0.2 移除超管代报)----
+        var agentUserId = _idOf(agent.get('user'));
+        if (agentUserId && agentUserId !== user.id) {
+          return _respond(response, 403, { code: 403, msg: '仅该容器的 owner 可上报' });
+        }
+        if (!agentUserId) {
+          agent.set('user', user);
+          await agent.save(null, { useMasterKey: true });
+          agentUserId = user.id;
+        }
+
+        // ---- 写交付物 ----
+        var obj = new Parse.Object('AgentDeliverable');
+        for (var i = 0; i < UPDATABLE.length; i++) {
+          var k = UPDATABLE[i];
+          if (p[k] === undefined) continue;
+          if (k === 'deliveredAt') obj.set('deliveredAt', new Date(p[k])); else obj.set(k, p[k]);
+        }
+        obj.set('agentId', agent.get('agentId') || p.agentId);
+        obj.set('agent', agent);
+        obj.set('agentObjectId', agent.id);
+        obj.set('user', user);
+        if (agent.get('company')) obj.set('company', agent.get('company'));
+        obj.set('reportedAt', new Date());
+        if (p.deliveredAt === undefined) obj.set('deliveredAt', new Date());
+        if (!p.status) obj.set('status', 'delivered');
+        var acl = new Parse.ACL();
+        acl.setReadAccess(user.id, true);
+        acl.setWriteAccess(user.id, true);
+        acl.setPublicReadAccess(false);
+        acl.setPublicWriteAccess(false);
+        obj.setACL(acl);
+        var saved = await obj.save(null, { useMasterKey: true });
+
+        // ---- 更新 agent 心跳 ----
+        agent.set('heartbeatAt', new Date());
+        agent.set('status', 'online');
+        await agent.save(null, { useMasterKey: true });
+
+        return _respond(response, 200, {
+          code: 200, id: saved.id, agentId: obj.get('agentId'),
+          agentObjectId: agent.id, title: p.title
+        });
+      }
+
+      // ---- list / byProject(沿用 v0.0.1)----
+      var q = new Parse.Query('AgentDeliverable');
+      if (p.agentId) q.equalTo('agentId', p.agentId);
+      if (p.project) q.equalTo('project', p.project);
+      if (!superAd) { if (uCompany) { q.equalTo('company', uCompany); } else { q.equalTo('user', user); } }
+      if (p.before) q.lessThan('deliveredAt', new Date(p.before));
+      q.descending('deliveredAt'); q.limit(Math.min(p.limit || 50, 200));
+      var rows = await q.find({ useMasterKey: true });
+      var items = rows.map(function (r) {
+        return { id: r.id, agentId: r.get('agentId'), title: r.get('title'), summary: r.get('summary'),
+          project: r.get('project'), projectName: r.get('projectName'), artifacts: r.get('artifacts') || [],
+          tags: r.get('tags') || [], deliveredAt: r.get('deliveredAt'), status: r.get('status') || 'delivered', extra: r.get('extra') };
+      });
+      if (act === 'byProject') {
+        var groups = {};
+        for (var j = 0; j < items.length; j++) {
+          var it = items[j]; var key = it.project || '未归类';
+          if (!groups[key]) groups[key] = { project: key, projectName: it.projectName || key, count: 0, items: [] };
+          groups[key].count++; groups[key].items.push(it);
+        }
+        return _respond(response, 200, { code: 200, groups: Object.keys(groups).map(function (k2) { return groups[k2]; }) });
+      }
+      return _respond(response, 200, { code: 200, count: items.length, deliverables: items });
+    } catch (e) {
+      return _respond(response, 500, { code: 500, msg: String(e && e.message || e) });
+    }
+  })();
+}
+function _respond(response, status, body) {
+  if (response && typeof response.status === 'function') { response.status(status); response.json(body); }
+  else if (response && typeof response.success === 'function') { status === 200 ? response.success(body) : response.error(body); }
+  return body;
+}

+ 11 - 0
docs/CHANGELOG.md

@@ -0,0 +1,11 @@
+# 更新日志
+
+## 0.0.2(2026-09-23,运维001)
+- fix(cli): `bin/skill-deliverable.mjs` 在 ES 模块里混用 `require('os')`,Node ≥20 抛
+  `ERR_AMBIGUOUS_MODULE_SYNTAX` → 改为 `import { hostname } from 'node:os'`
+- add(cloud): `cloud/deliverable-report-v2.platform.js` —— **平台形态**(`handler(request,response)`
+  + 全局 `params`)的 v2 实现。原 `deliverable-report-v2.js` 是 `Parse.Cloud.define` 形态,
+  直接写进 `Function` 记录跑不起来。已按平台形态上线到 `lfYlgU7SkK`(version 0.0.2)。
+- fix(cloud): `user` / `company` 是 Parse 指针,写字符串会 schema mismatch;
+  owner 比较改为取 id(兼容指针与字符串)
+- docs: 部署指南补「关键 ID 核查」「技能只读哪些路径」「云函数改记录即生效」

+ 61 - 1
docs/deployment-guide.md

@@ -113,4 +113,64 @@ A: 可以。`skill-deliverable report --title "测试" --project test`。失败
 
 | 版本 | 日期 | 变更 |
 |------|------|------|
-| 0.0.1 | 2026-09-23 | 初版:分离 fmode-hub 独立成技能 + 云函数 v2 改良 |
+| 0.0.1 | 2026-09-23 | 初版:分离 fmode-hub 独立成技能 + 云函数 v2 改良 |
+---
+
+## 五、运维001 实测补充(2026-09-23)
+
+### 5.1 云函数代码必须是「平台形态」,不是 Parse.Cloud.define
+
+本平台 `/api/functions` 的契约是:代码里定义 `function handler(request, response)`,
+可用的全局变量是 `Parse` / `Psql` / `request` / `response` / `params` / `user`;
+鉴权信息挂在 `request.user` / `request.company` / `request.account`。
+
+仓库里的 `cloud/deliverable-report-v2.js` 用的是 `Parse.Cloud.define('deliverable-report', ...)`,
+**直接写进 Function 记录会跑不起来**。平台形态的正确版本见
+`cloud/deliverable-report-v2.platform.js`(已按此上线到 `lfYlgU7SkK`,version 0.0.2)。
+
+### 5.2 云函数不需要「发布」,改 `Function` 记录即生效
+
+executor 每次调用都从库里查 `Function` 记录(无内存缓存),所以:
+- 更新 = 更新 `Function.code`(+ `version` / `updatedAt`)
+- **不需要**部署源码、不需要重启服务
+
+Parse REST 对 `Function` 类有 CLP 限制(masterKey 也被拒,code 119),
+运维侧用 nova 库直改 `"Function"` 表那一行即可。
+
+### 5.3 字段类型:`user` / `company` 是 Parse 指针,不是字符串
+
+- `AgentDeliverable.user` / `.company` 的 schema 是 **Pointer**,写字符串会报
+  `schema mismatch for AgentDeliverable.user; expected Pointer<_User> but got String`。
+  要传 `Parse.User` 对象(ACL 里才用 `user.id` 字符串)。
+- `FmodeAgent.user` 同理,owner 比较要取 `.id`(`typeof v === 'string' ? v : v.id`)。
+
+### 5.4 关键 ID 核查(★ 安装前必做)
+
+容器能上报的三个前提,缺一个就 403/500:
+
+| 前提 | 检查方法 |
+|------|----------|
+| `FMODE_AGENT_ID` 有值且与 `FmodeAgent.agentId` 一致 | 见 5.5 |
+| 容器的 `sessionToken` **属于该 agent 的 owner** | 用 token 查 `_Session."user"`,必须等于 `FmodeAgent."user"` |
+| `FmodeAgent` 记录存在(或允许自注册) | `SELECT * FROM "FmodeAgent" WHERE "agentId"='...'` |
+
+**实测踩到的坑**:某容器 `fmode-identity.json` 里的 `session_token` 是**别人(平台主账号)的**,
+且容器 env 里 `FMODE_SESSION_TOKEN` 是**空字符串** —— 于是 owner 校验必然 403。
+批量开通的容器尤其要逐个核。
+
+### 5.5 技能只读这几个位置(`fmode-identity.json` 不在其中!)
+
+`lib/index.mjs` 的解析链:
+
+- agentId:`env.FMODE_AGENT_ID` → `env.AGENT_ID` → `~/.fmode/config.json#agentId` → `./.fmode/config.json#agentId` → hostname(仅兜底,会 404)
+- sessionToken:`env.FMODE_SESSION_TOKEN` → `~/.fmode/config/user.json#sessionToken` → `~/.fmode/config.json#sessionToken` → `./.fmode/config.json#sessionToken`
+
+**注意**:`~` 取的是 `$HOME`。这些容器里 `hermes` 用户的 HOME 是 **`/opt/data`**(持久卷),
+`root` 的 HOME 是 `/root`(容器层,重建即丢)。所以持久化的正确位置是
+**`/opt/data/.fmode/config.json`**(同时给 `/root/.fmode/config.json` 兜底)。
+
+### 5.6 CLI 的 Node 版本坑
+
+`bin/skill-deliverable.mjs` 原稿在 ES 模块里用了 `require('os')`,
+Node ≥ 20 会直接抛 `ERR_AMBIGUOUS_MODULE_SYNTAX`(top-level await + require 混用)。
+已改为 `import { hostname } from 'node:os'`。