@sleep2agi/commhub-server 0.9.0-preview.115 → 0.9.0-preview.117

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.
@@ -0,0 +1,244 @@
1
+ // Agent 团队(看板 #764,父卡 #750):Agent(节点)自己的组织树,和人的部门树(network_departments)完全独立。
2
+ //
3
+ // 只增:两张新表。network_agent_teams = 团队(多层,同一上级下不重名);network_agent_team_members = 节点归哪个团队
4
+ // (一个节点最多一个团队;行存在 = 在这个团队)。nodes / 部门表一概不动,已有接口的响应不变。
5
+ //
6
+ // 权限(每次请求按 owner_user_id 现算,不存展开结果):
7
+ // 网络 owner / admin、Hub 管理员:全部;
8
+ // 团队的 owner_user_id:管这个团队的子树 —— 改名 / 排序 / 设 lead、在子树里建子团队、在子树里挪动、把节点放进 / 移出子树;
9
+ // 但不能改自己那个团队的 owner,也不能把它挪出去(这两样要求「上级也在我的范围里」);
10
+ // 其他成员、节点令牌:只读。viewer 当 owner 也不获得写权限。
11
+ // 读的时候 JOIN nodes:节点删了,它的归属 / lead 自然消失;删节点的两条路径另外调 clearNodeFromAgentTeams 真删行。
12
+
13
+ import { db } from "./db.js";
14
+
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS network_agent_teams (
17
+ network_id TEXT NOT NULL,
18
+ team_id TEXT NOT NULL,
19
+ name TEXT NOT NULL,
20
+ parent_id TEXT,
21
+ lead_node_id TEXT,
22
+ owner_user_id TEXT,
23
+ sort INTEGER NOT NULL DEFAULT 0,
24
+ created_by TEXT,
25
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
26
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ PRIMARY KEY (network_id, team_id)
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_network_agent_teams_parent ON network_agent_teams(network_id, parent_id);
30
+ CREATE TABLE IF NOT EXISTS network_agent_team_members (
31
+ network_id TEXT NOT NULL,
32
+ node_id TEXT NOT NULL,
33
+ team_id TEXT NOT NULL,
34
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
35
+ PRIMARY KEY (network_id, node_id)
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_network_agent_team_members_team ON network_agent_team_members(network_id, team_id);
38
+ `);
39
+
40
+ export const AGENT_TEAM_SCOPE_DENIED = "agent_team_scope_denied";
41
+ const MAX_DEPTH = 10;
42
+ const MAX_TEAMS = 500;
43
+ const NAME_MAX = 40;
44
+
45
+ type Row = { team_id: string; name: string; parent_id: string | null; lead_node_id: string | null; owner_user_id: string | null; sort: number; created_at: string; updated_at: string };
46
+ type Fail = { ok: false; status: number; error: string };
47
+ const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
48
+ const nil = (v: unknown) => v === undefined || v === null || v === "";
49
+
50
+ function rows(networkId: string): Row[] {
51
+ return db.all<Row>(
52
+ "SELECT team_id, name, parent_id, lead_node_id, owner_user_id, sort, created_at, updated_at FROM network_agent_teams WHERE network_id = ?1 ORDER BY sort, created_at, team_id",
53
+ networkId,
54
+ );
55
+ }
56
+ function one(networkId: string, id: unknown): Row | null {
57
+ if (typeof id !== "string") return null;
58
+ return rows(networkId).find((r) => r.team_id === id) ?? null;
59
+ }
60
+ function parentOf(networkId: string): Map<string, string | null> {
61
+ return new Map(rows(networkId).map((r) => [r.team_id, r.parent_id]));
62
+ }
63
+ /** id 和它的全部上级(从自己往上)。 */
64
+ function chain(parent: Map<string, string | null>, id: string | null): string[] {
65
+ const out: string[] = [];
66
+ let cur = id;
67
+ while (cur && parent.has(cur) && !out.includes(cur)) { out.push(cur); cur = parent.get(cur) ?? null; }
68
+ return out;
69
+ }
70
+ function height(parent: Map<string, string | null>, id: string): number {
71
+ let h = 1;
72
+ for (const [c, p] of parent) if (p === id && c !== id) h = Math.max(h, 1 + height(parent, c));
73
+ return h;
74
+ }
75
+ const isMember = (networkId: string, userId: string) =>
76
+ !!db.get("SELECT user_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
77
+ type Hidden = (n: NodeRef) => boolean;
78
+ /** 写路径用同一个可见性判据:不在本网络、或调用者看不见(只看授权 Agent)→ 一律当不存在(同一个 404,不能拿来探测)。 */
79
+ function visibleNode(networkId: string, nodeId: unknown, hidden: Hidden): boolean {
80
+ if (typeof nodeId !== "string") return false;
81
+ const n = db.get<NodeRef>("SELECT node_id, alias, display_name FROM nodes WHERE network_id = ?1 AND node_id = ?2", networkId, nodeId);
82
+ return !!n && !hidden(n);
83
+ }
84
+ const publicTeam = (networkId: string, id: string, hidden: Hidden) => listAgentTeams(networkId, hidden).find((t) => t.id === id)!;
85
+ const cleanName = (raw: unknown): string | null => {
86
+ if (typeof raw !== "string") return null;
87
+ const n = raw.trim();
88
+ return n && [...n].length <= NAME_MAX ? n : null;
89
+ };
90
+ function nameTaken(networkId: string, parentId: string | null, name: string, except?: string): boolean {
91
+ return rows(networkId).some((r) => r.parent_id === parentId && r.name === name && r.team_id !== except);
92
+ }
93
+
94
+ /** userId 能管的团队:他当 owner 的团队及全部下级(owner 不在本网络 / 是 viewer → 空)。 */
95
+ export function ownedScope(networkId: string, userId: string): Set<string> {
96
+ const role = db.get<{ role: string | null }>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId)?.role;
97
+ const out = new Set<string>();
98
+ if (!role || role === "viewer") return out;
99
+ const all = rows(networkId);
100
+ const parent = new Map(all.map((r) => [r.team_id, r.parent_id]));
101
+ const owned = new Set(all.filter((r) => r.owner_user_id === userId).map((r) => r.team_id));
102
+ for (const r of all) if (chain(parent, r.team_id).some((id) => owned.has(id))) out.add(r.team_id);
103
+ return out;
104
+ }
105
+
106
+ type NodeRef = { node_id: string; alias: string | null; display_name: string | null };
107
+ /** 整棵树 + 每个团队的成员 / lead / owner。hidden(node) = true 的节点不出现(只看授权 Agent 的成员)。 */
108
+ export function listAgentTeams(networkId: string, hidden: Hidden) {
109
+ const nodes = new Map(db.all<NodeRef>("SELECT node_id, alias, display_name FROM nodes WHERE network_id = ?1", networkId).map((n) => [n.node_id, n]));
110
+ const people = new Map(db.all<{ user_id: string; username: string; display_name: string | null }>(
111
+ "SELECT u.user_id, u.username, u.display_name FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.network_id = ?1", networkId,
112
+ ).map((u) => [u.user_id, !u.display_name || u.display_name === u.username ? "" : u.display_name])); // 同 requirements-people 的显示名规则
113
+ const seen = (id: string | null) => (id && nodes.has(id) && !hidden(nodes.get(id)!) ? nodes.get(id)! : null);
114
+ const members = new Map<string, NodeRef[]>();
115
+ for (const m of db.all<{ node_id: string; team_id: string }>("SELECT node_id, team_id FROM network_agent_team_members WHERE network_id = ?1 ORDER BY node_id", networkId)) {
116
+ const n = seen(m.node_id);
117
+ if (n) members.set(m.team_id, [...(members.get(m.team_id) ?? []), n]);
118
+ }
119
+ return rows(networkId).map((r) => ({
120
+ id: r.team_id, name: r.name, parent_id: r.parent_id, sort: r.sort,
121
+ lead: seen(r.lead_node_id),
122
+ owner: r.owner_user_id && people.has(r.owner_user_id) ? { user_id: r.owner_user_id, display_name: people.get(r.owner_user_id)! } : null,
123
+ members: members.get(r.team_id) ?? [],
124
+ created_at: r.created_at, updated_at: r.updated_at,
125
+ }));
126
+ }
127
+
128
+ /** #765: node-token identity only; no fallback to a human's department.
129
+ * Reuse the REST projection's network joins and display-name privacy rules.
130
+ */
131
+ export function agentTeamWhoami(networkId: string, nodeId: string | null) {
132
+ if (!nodeId) return { ok: false, error: "node_identity_unbound" };
133
+ const teams = listAgentTeams(networkId, () => false);
134
+ const self = teams.find(t => t.members.some(n => n.node_id === nodeId));
135
+ if (!self) return { ok: true, source: "none", node_id: nodeId, team: null, ancestors: [], lead: null, owner: null, agents: [], truncated: false };
136
+ const byId = new Map(teams.map(t => [t.id, t]));
137
+ const seen = new Set([self.id]);
138
+ const ancestors: { id: string; name: string }[] = [];
139
+ for (let id = self.parent_id; id && !seen.has(id);) {
140
+ const parent = byId.get(id);
141
+ if (!parent) break;
142
+ seen.add(id);
143
+ ancestors.push({ id, name: parent.name });
144
+ id = parent.parent_id;
145
+ }
146
+ return { ok: true, source: "node", node_id: nodeId, team: { id: self.id, name: self.name }, ancestors,
147
+ lead: self.lead, owner: self.owner, agents: self.members.slice(0, 50), truncated: self.members.length > 50 };
148
+ }
149
+
150
+ /** scope = null 表示全管;否则是调用者能管的团队集合。 */
151
+ export function createAgentTeam(networkId: string, actor: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
152
+ const name = cleanName(body.name);
153
+ if (!name) return fail(400, "invalid_team_name");
154
+ const parentId = nil(body.parent_id) ? null : body.parent_id;
155
+ if (parentId !== null && !one(networkId, parentId)) return fail(400, "parent_not_found");
156
+ if (scope && (parentId === null || !scope.has(parentId as string))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
157
+ if (chain(parentOf(networkId), parentId as string | null).length + 1 > MAX_DEPTH) return fail(400, "team_too_deep");
158
+ if (nameTaken(networkId, parentId as string | null, name)) return fail(409, "team_name_taken");
159
+ if (rows(networkId).length >= MAX_TEAMS) return fail(400, "too_many_teams");
160
+ const id = `team_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
161
+ const sort = rows(networkId).filter((r) => r.parent_id === parentId).length;
162
+ db.run("INSERT INTO network_agent_teams (network_id, team_id, name, parent_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", [networkId, id, name, parentId, sort, actor]);
163
+ return { ok: true as const, team: publicTeam(networkId, id, hidden) };
164
+ }
165
+
166
+ export function updateAgentTeam(networkId: string, id: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
167
+ const cur = one(networkId, id);
168
+ if (!cur) return fail(404, "team_not_found");
169
+ // 子树 owner:团队本身要在范围里;改 owner / 挪动还要求它的上级也在范围里(不能改自己那个团队的 owner、不能挪出去)。
170
+ if (scope && !scope.has(id)) return fail(403, AGENT_TEAM_SCOPE_DENIED);
171
+ const parentInScope = !scope || (cur.parent_id !== null && scope.has(cur.parent_id));
172
+ const next = { ...cur };
173
+ let touched = false;
174
+ if (body.name !== undefined) {
175
+ const n = cleanName(body.name);
176
+ if (!n) return fail(400, "invalid_team_name");
177
+ next.name = n; touched = true;
178
+ }
179
+ if (body.parent_id !== undefined) {
180
+ const p = nil(body.parent_id) ? null : body.parent_id;
181
+ if (p !== null && !one(networkId, p)) return fail(400, "parent_not_found");
182
+ if (scope && (!parentInScope || p === null || !scope.has(p as string))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
183
+ const parent = parentOf(networkId);
184
+ if (p !== null && chain(parent, p as string).includes(id)) return fail(400, "team_cycle");
185
+ if (chain(parent, p as string | null).length + height(parent, id) > MAX_DEPTH) return fail(400, "team_too_deep");
186
+ next.parent_id = p as string | null; touched = true;
187
+ }
188
+ if (body.sort !== undefined) {
189
+ if (!Number.isInteger(body.sort)) return fail(400, "invalid_sort");
190
+ next.sort = body.sort as number; touched = true;
191
+ }
192
+ if (body.lead_node_id !== undefined) {
193
+ const l = nil(body.lead_node_id) ? null : body.lead_node_id;
194
+ if (l !== null && !visibleNode(networkId, l, hidden)) return fail(404, "node_not_found");
195
+ next.lead_node_id = l as string | null; touched = true;
196
+ }
197
+ if (body.owner_user_id !== undefined) {
198
+ const o = nil(body.owner_user_id) ? null : body.owner_user_id;
199
+ if (!parentInScope) return fail(403, AGENT_TEAM_SCOPE_DENIED); // 先判权限,再判成员:没权限的人不能借此探测谁是成员
200
+ if (o !== null && (typeof o !== "string" || !isMember(networkId, o))) return fail(400, "owner_not_member");
201
+ next.owner_user_id = o as string | null; touched = true;
202
+ }
203
+ if (!touched) return fail(400, "empty_patch");
204
+ if ((next.name !== cur.name || next.parent_id !== cur.parent_id) && nameTaken(networkId, next.parent_id, next.name, id)) return fail(409, "team_name_taken");
205
+ db.run(
206
+ "UPDATE network_agent_teams SET name = ?3, parent_id = ?4, sort = ?5, lead_node_id = ?6, owner_user_id = ?7, updated_at = datetime('now') WHERE network_id = ?1 AND team_id = ?2",
207
+ [networkId, id, next.name, next.parent_id, next.sort, next.lead_node_id, next.owner_user_id],
208
+ );
209
+ return { ok: true as const, team: publicTeam(networkId, id, hidden) };
210
+ }
211
+
212
+ /** 只删没有子团队的团队;成员归属同一事务清掉(节点变回未分配)。 */
213
+ export function deleteAgentTeam(networkId: string, id: string, scope: Set<string> | null) {
214
+ const cur = one(networkId, id);
215
+ if (!cur) return fail(404, "team_not_found");
216
+ if (scope && (cur.parent_id === null || !scope.has(cur.parent_id))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
217
+ const children = rows(networkId).filter((r) => r.parent_id === id).length;
218
+ if (children) return { ...fail(409, "team_has_children"), children };
219
+ db.transaction(() => {
220
+ db.run("DELETE FROM network_agent_teams WHERE network_id = ?1 AND team_id = ?2", [networkId, id]);
221
+ db.run("DELETE FROM network_agent_team_members WHERE network_id = ?1 AND team_id = ?2", [networkId, id]);
222
+ });
223
+ return { ok: true as const, deleted: id };
224
+ }
225
+
226
+ /** 节点归一个团队(null = 未分配)。子树 owner:目标团队、节点当前的团队(有的话)都要在范围里。 */
227
+ export function setNodeAgentTeam(networkId: string, nodeId: string, scope: Set<string> | null, hidden: Hidden, teamId: unknown) {
228
+ if (!visibleNode(networkId, nodeId, hidden)) return fail(404, "node_not_found");
229
+ const target = nil(teamId) ? null : teamId;
230
+ if (target !== null && !one(networkId, target)) return fail(400, "team_not_found");
231
+ const current = db.get<{ team_id: string }>("SELECT team_id FROM network_agent_team_members WHERE network_id = ?1 AND node_id = ?2", networkId, nodeId)?.team_id ?? null;
232
+ if (scope && ((target === null && current === null) || (target !== null && !scope.has(target as string)) || (current !== null && !scope.has(current)))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
233
+ db.transaction(() => {
234
+ db.run("DELETE FROM network_agent_team_members WHERE network_id = ?1 AND node_id = ?2", [networkId, nodeId]);
235
+ if (target !== null) db.run("INSERT INTO network_agent_team_members (network_id, node_id, team_id) VALUES (?1, ?2, ?3)", [networkId, nodeId, target]);
236
+ });
237
+ return { ok: true as const, node_id: nodeId, team_id: target as string | null };
238
+ }
239
+
240
+ /** 节点删除时调用:清掉它的团队归属,指向它的 lead 置空。 */
241
+ export function clearNodeFromAgentTeams(nodeId: string): void {
242
+ db.run("DELETE FROM network_agent_team_members WHERE node_id = ?1", [nodeId]);
243
+ db.run("UPDATE network_agent_teams SET lead_node_id = NULL WHERE lead_node_id = ?1", [nodeId]);
244
+ }
package/src/auth.ts CHANGED
@@ -5,7 +5,7 @@ import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken,
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
6
  import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
7
7
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
8
- import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
8
+ import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
9
9
 
10
10
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
11
11
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -514,7 +514,7 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
514
514
  // single-network user to "ambiguous" or auto-resolve writes INTO the
515
515
  // deleted network. Remove memberships with the network.
516
516
  db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
517
- deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); // #2144, #419 (one line: docs pin auth.ts line numbers)
517
+ deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); for (const t of AGENT_TEAM_TABLES) if (tableExists(t)) db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [networkId]); // #2144, #419, #764 (one line: docs pin auth.ts line numbers)
518
518
  });
519
519
  return { ok: true };
520
520
  }
@@ -749,8 +749,8 @@ export function usernameCollidesWithAgent(networkId: string, userId: string): bo
749
749
  export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted"; taskAccess?: TaskAccessMode } = {}): { ok: boolean; error?: string } {
750
750
  if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
751
751
  const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
752
- // 任务范围:新成员默认 NEW_MEMBER_TASK_ACCESS(task-access.ts 一处定义)。
753
- const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess : NEW_MEMBER_TASK_ACCESS;
752
+ // 任务范围:显式传值优先;否则 newMemberTaskAccess(role)(#746:member/viewer 'scoped',admin 'all')。
753
+ const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess : newMemberTaskAccess(role);
754
754
  if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
755
755
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
756
756
  if (existing) return { ok: false, error: "user already a member" };
@@ -816,7 +816,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
816
816
  if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
817
817
  // Add member + increment used count
818
818
  db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, 'granted', ?5)",
819
- [invite.network_id, userId, invite.role, invite.created_by, NEW_MEMBER_TASK_ACCESS]);
819
+ [invite.network_id, userId, invite.role, invite.created_by, newMemberTaskAccess(invite.role)]);
820
820
  db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
821
821
  // 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
822
822
  if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
@@ -953,6 +953,7 @@ const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
953
953
  ["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
954
954
  ["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
955
955
  ];
956
+ const AGENT_TEAM_TABLES = ["network_agent_teams", "network_agent_team_members"]; // #764
956
957
  const NETWORK_CLEANUP_TABLES = [
957
958
  "network_members", "network_member_agent_grants", "network_invites", "api_tokens",
958
959
  "node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
@@ -962,6 +963,8 @@ const NETWORK_CLEANUP_TABLES = [
962
963
  "network_tags",
963
964
  // 组织架构(board #419):部门是网络本身的元数据。
964
965
  "network_departments",
966
+ // Agent 团队(#764):Agent 自己的组织树,同样是网络元数据。
967
+ ...AGENT_TEAM_TABLES,
965
968
  ];
966
969
 
967
970
  function tableExists(name: string): boolean {
package/src/db.ts CHANGED
@@ -1648,6 +1648,9 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_creator_node ON sc
1648
1648
  // success in between re-arms the alert.
1649
1649
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_at TEXT"); } catch {}
1650
1650
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_key TEXT"); } catch {}
1651
+ // Board #757 — one warning per continuous target-offline episode. The scheduler
1652
+ // clears this as soon as the target session is online again; older Hubs ignore it.
1653
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN offline_alert_at TEXT"); } catch {}
1651
1654
 
1652
1655
  // RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
1653
1656
  // This is intentionally separate from Hub scheduled_tasks: these rows are
@@ -55,7 +55,8 @@ beforeAll(async () => {
55
55
  expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
56
56
  const login = await send("", "POST", "/api/auth/login", { username, password: PW });
57
57
  U[k] = { token: login.body.token, id: login.body.user.user_id };
58
- if (scoped && role !== "admin") expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
58
+ // #746 起新成员默认就是 scoped;allMember 显式放宽到 'all'(模拟升级前的老成员)。
59
+ if (role !== "admin") expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/task-grants`, { task_access: scoped ? "scoped" : "all" })).status).toBe(200);
59
60
  };
60
61
  await add("admin", "admin");
61
62
  for (const k of ["head", "parentHead", "member", "be", "sales"]) await add(k, "member");
@@ -257,7 +257,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
257
257
  get_adopt_request: "always", ack_adopt_request: "always",
258
258
  send_desktop_message: "always", // 发给人的私信,不是派活
259
259
  // 读
260
- get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
260
+ get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
261
261
  list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
262
262
  get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
263
263
  read_node_file: "read", tail_node_logs: "read",
@@ -58,6 +58,8 @@ beforeAll(async () => {
58
58
  const mk = async (username: string, role: string) => {
59
59
  expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
60
60
  const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ // #746 起新成员默认 scoped;这里的 member / viewer 模拟老成员('all'),scoped 那位下面再收窄。
62
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${login.body.user.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
61
63
  return { token: login.body.token as string, id: login.body.user.user_id as string };
62
64
  };
63
65
  const stamp = Date.now();
@@ -78,7 +78,7 @@ beforeAll(async () => {
78
78
  const stamp = Date.now();
79
79
  alice = await mk(`rs_alice_${stamp}`);
80
80
  carol = await mk(`rs_carol_${stamp}`);
81
- // 新成员默认值 NEW_MEMBER_TASK_ACCESS 暂时是 'all',alice 要显式设成 scoped。
81
+ // #746 起新成员默认就是 scoped;这里仍显式设一次,不依赖默认值。
82
82
  expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
83
83
  db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
84
84
 
@@ -59,6 +59,8 @@ beforeAll(async () => {
59
59
  expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
60
60
  const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
61
  U.member = { token: login.body.token, id: login.body.user.user_id };
62
+ // #746 起新成员默认 scoped;这里测的是 'all' 成员的 REST 键,显式放宽。
63
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { task_access: "all" })).status).toBe(200);
62
64
  for (const [k, alias] of [["nodeA", "示例-甲"], ["nodeB", "示例-乙"], ["nodeC", "示例-丙"]] as const) {
63
65
  const nodeId = `node_sp_${k}`;
64
66
  U[k] = { token: createNetworkTokenForNode(U.owner.id, NET, alias, nodeId).token!, id: nodeId };
@@ -108,23 +108,27 @@ describe("scheduler doorbell for sessions reported without node_id", () => {
108
108
  }
109
109
  });
110
110
 
111
- test("a live subscriber counts even when the session row says offline", async () => {
111
+ test("an offline session is skipped even if a stale subscriber is still connected", async () => {
112
112
  seedSession("offline", null);
113
113
  const sub = subscribe();
114
114
  try {
115
115
  await settle();
116
116
  const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
117
117
  await settle();
118
- expect(result.status).toBe("delivered");
119
- expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(1);
118
+ expect(result.status).toBe("skipped");
119
+ expect(db.get<{ error_code: string; task_id: string | null }>("SELECT error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", result.runId))
120
+ .toEqual({ error_code: "target_offline", task_id: null });
121
+ expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(0);
120
122
  } finally {
121
123
  await sub.close();
122
124
  }
123
125
  });
124
126
 
125
- test("offline session with nobody listening stays queued", () => {
127
+ test("offline session with nobody listening is skipped without a task", () => {
126
128
  seedSession("offline", null);
127
129
  const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
128
- expect(result.status).toBe("queued");
130
+ expect(result.status).toBe("skipped");
131
+ expect(db.get<{ error_code: string; task_id: string | null }>("SELECT error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", result.runId))
132
+ .toEqual({ error_code: "target_offline", task_id: null });
129
133
  });
130
134
  });
@@ -0,0 +1,131 @@
1
+ // Board #757 — an offline scheduled target produces an auditable skip, not a
2
+ // doomed task; each offline episode warns its human/Agent creator once.
3
+ // Run: COMMHUB_DB=/tmp/board757.db bun test src/scheduled-offline.test.ts
4
+ // PG: registered in tests/test2123-hub-postgres-ladder/run.sh.
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { register } from "./auth.js";
7
+ import { db } from "./db.js";
8
+ import { dispatchScheduledOccurrence, getScheduleRow } from "./scheduled-tasks.js";
9
+ import { SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, scheduledTargetOffline } from "./scheduled-offline.js";
10
+
11
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
12
+ if (!activeDbPath) throw new Error("scheduled-offline requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
13
+
14
+ const stamp = Date.now();
15
+ const TARGET_ID = `n_offline_target_${stamp}`;
16
+ const TARGET = `offline-target-${stamp}`;
17
+ const CREATOR_ID = `n_offline_creator_${stamp}`;
18
+ const CREATOR = `offline-creator-${stamp}`;
19
+ let NET = "", USER = "", slot = 0;
20
+
21
+ function seedSchedule(createdByNodeId: string | null = null): any {
22
+ const id = `sched_offline_${crypto.randomUUID()}`;
23
+ db.run(
24
+ `INSERT INTO scheduled_tasks
25
+ (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias,
26
+ task_content, priority, schedule_type, schedule_json, timezone, status, next_run_at)
27
+ VALUES (?1, ?2, ?3, ?4, '离线巡检', ?5, ?6, 'ping', 'normal', 'interval',
28
+ '{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
29
+ [id, NET, USER, createdByNodeId, TARGET_ID, TARGET],
30
+ );
31
+ return getScheduleRow(id)!;
32
+ }
33
+
34
+ function fire(schedule: any) {
35
+ const at = new Date(Date.UTC(2026, 9, 8, 0, 0, 0) + (++slot) * 60_000).toISOString();
36
+ return dispatchScheduledOccurrence(schedule, at, false, new Date());
37
+ }
38
+
39
+ function setTarget(status: "idle" | "offline", minutesAgo = 0) {
40
+ const seen = new Date(Date.now() - minutesAgo * 60_000).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
41
+ db.run("UPDATE sessions SET status = ?1, updated_at = ?2, last_seen_at = ?2 WHERE node_id = ?3 AND network_id = ?4", [status, seen, TARGET_ID, NET]);
42
+ }
43
+
44
+ const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND to_node_id = ?2", NET, TARGET_ID)!.n;
45
+ const humanNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
46
+ "SELECT meta_json, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
47
+ NET, USER, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND,
48
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline.schedule_id === scheduleId);
49
+ const agentNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
50
+ "SELECT meta_json, content FROM inbox WHERE network_id = ?1 AND node_id = ?2 AND from_session = 'hub' ORDER BY created_at, id",
51
+ NET, CREATOR_ID,
52
+ ).filter((row) => JSON.parse(row.meta_json).schedule_target_offline?.schedule_id === scheduleId);
53
+
54
+ beforeAll(() => {
55
+ const owner = register(`offline_owner_${stamp}`, "OfflineOwner123!", undefined, "seed");
56
+ expect(owner.ok).toBe(true);
57
+ NET = owner.network_id!; USER = owner.user!.user_id;
58
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [TARGET_ID, TARGET, NET, USER]);
59
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [CREATOR_ID, CREATOR, NET, USER]);
60
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_target_${stamp}`, TARGET, TARGET_ID, NET]);
61
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_creator_${stamp}`, CREATOR, CREATOR_ID, NET]);
62
+ });
63
+
64
+ afterAll(() => {
65
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND]);
66
+ });
67
+
68
+ describe("#757 scheduled target offline", () => {
69
+ test("offline skips without a task, warns a human once, and online recovery dispatches normally", () => {
70
+ const schedule = seedSchedule();
71
+ const online = fire(schedule);
72
+ expect(online.status).toBe("delivered");
73
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [online.taskId]);
74
+
75
+ setTarget("offline");
76
+ const before = taskCount();
77
+ const first = fire(schedule), second = fire(schedule);
78
+ expect([first.status, second.status]).toEqual(["skipped", "skipped"]);
79
+ expect(taskCount()).toBe(before);
80
+ for (const run of [first, second]) {
81
+ expect(db.get("SELECT status, error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", run.runId))
82
+ .toEqual({ status: "skipped", error_code: "target_offline", task_id: null });
83
+ }
84
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(1);
85
+ expect(humanNotices(schedule.schedule_id)[0].content).toContain("恢复在线后");
86
+
87
+ setTarget("idle");
88
+ const recovered = fire(schedule);
89
+ expect(recovered.status).toBe("delivered");
90
+ expect(recovered.taskId).toBeTruthy();
91
+ db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [recovered.taskId]);
92
+
93
+ setTarget("offline");
94
+ expect(fire(schedule).status).toBe("skipped");
95
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(2);
96
+ });
97
+
98
+ test("an Agent-created schedule warns the creator inbox, never the owner's user inbox", () => {
99
+ const schedule = seedSchedule(CREATOR_ID);
100
+ setTarget("offline");
101
+ expect(fire(schedule).status).toBe("skipped");
102
+ expect(fire(schedule).status).toBe("skipped");
103
+ expect(agentNotices(schedule.schedule_id)).toHaveLength(1);
104
+ expect(agentNotices(schedule.schedule_id)[0].content).toContain("不需要回复");
105
+ expect(humanNotices(schedule.schedule_id)).toHaveLength(0);
106
+ });
107
+
108
+ test("an idle session older than the existing five-minute cutoff is offline", () => {
109
+ const schedule = seedSchedule();
110
+ setTarget("idle", 6);
111
+ const before = taskCount();
112
+ const run = fire(schedule);
113
+ expect(run.status).toBe("skipped");
114
+ expect(taskCount()).toBe(before);
115
+ expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", run.runId)?.error_code).toBe("target_offline");
116
+ });
117
+
118
+ test("recent SQLite and ISO timestamps all keep an idle target online", () => {
119
+ const now = Date.now();
120
+ const isoMs = new Date(now - 1_000).toISOString();
121
+ const values = [
122
+ isoMs.slice(0, 19).replace("T", " "),
123
+ `${isoMs.slice(0, 19)}Z`,
124
+ isoMs,
125
+ ];
126
+ for (const value of values) {
127
+ db.run("UPDATE sessions SET status = 'idle', last_seen_at = ?1 WHERE node_id = ?2 AND network_id = ?3", [value, TARGET_ID, NET]);
128
+ expect(scheduledTargetOffline(TARGET_ID, TARGET, NET, now)).toBe(false);
129
+ }
130
+ });
131
+ });
@@ -0,0 +1,66 @@
1
+ import { sendAgentNotice } from "./agent-notice.js";
2
+ import { db, uuidv4 } from "./db.js";
3
+ import { parseDbTimestampMs } from "./db-timestamp.js";
4
+ import { pendingInboxCount } from "./inbox-count.js";
5
+ import { pushEvent } from "./push.js";
6
+
7
+ export const SCHEDULE_TARGET_OFFLINE_NOTICE_KIND = "schedule_target_offline";
8
+ const SESSION_STALE_MS = 5 * 60_000;
9
+
10
+ type SessionRow = { status: string | null; updated_at: string | null; last_seen_at: string | null };
11
+
12
+ /** Same status + five-minute timestamp rule as the existing task delivery resolver. */
13
+ export function scheduledTargetOffline(nodeId: string, alias: string, networkId: string, nowMs = Date.now()): boolean {
14
+ const session = db.get<SessionRow>(
15
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
16
+ nodeId, networkId,
17
+ ) ?? db.get<SessionRow>(
18
+ "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
19
+ alias, networkId,
20
+ );
21
+ if (!session) return true;
22
+ const raw = session.last_seen_at || session.updated_at;
23
+ const seen = raw ? parseDbTimestampMs(raw) : 0;
24
+ return String(session.status || "").toLowerCase() === "offline" || !Number.isFinite(seen) || nowMs - seen > SESSION_STALE_MS;
25
+ }
26
+
27
+ export type OfflineNotice = {
28
+ scheduleId: string; scheduleName: string; networkId: string;
29
+ createdBy: string | null; createdByNodeId: string | null;
30
+ targetAlias: string;
31
+ };
32
+
33
+ function noticeText(n: OfflineNotice): string {
34
+ return `定时任务「${n.scheduleName}」的目标节点 ${n.targetAlias} 已离线,本轮已跳过且没有创建任务。节点恢复在线后,后续轮次会自动照常派发。`;
35
+ }
36
+
37
+ /** Post-commit delivery. The transaction has already claimed this offline episode. */
38
+ export function sendOfflineNotice(n: OfflineNotice): string | null {
39
+ try {
40
+ const text = noticeText(n);
41
+ const meta = { schedule_target_offline: { schedule_id: n.scheduleId, target_alias: n.targetAlias, reason: "target_offline" } };
42
+ if (!n.createdByNodeId) {
43
+ if (!n.createdBy) return null;
44
+ return sendAgentNotice({
45
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.targetAlias,
46
+ kind: SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, title: "定时任务目标节点离线", text,
47
+ severity: "warning", meta, idPrefix: "dm_sched_offline_",
48
+ });
49
+ }
50
+ const creator = db.get<{ alias: string | null }>(
51
+ "SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", n.createdByNodeId, n.networkId,
52
+ );
53
+ if (!creator?.alias) return null;
54
+ const id = `sched_offline_${uuidv4()}`;
55
+ db.run(
56
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id, meta_json)
57
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'hub', ?5, ?6)`,
58
+ [id, creator.alias, n.createdByNodeId, `[定时任务目标节点离线] ${text}\n(这是告警,不需要回复。)`, n.networkId, JSON.stringify(meta)],
59
+ );
60
+ pushEvent(creator.alias, { type: "new_message", inbox_count: pendingInboxCount(creator.alias, n.networkId), from: "hub", message_id: id }, n.networkId);
61
+ return id;
62
+ } catch (e: any) {
63
+ console.error(`[scheduled-tasks] offline notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
64
+ return null;
65
+ }
66
+ }