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

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.
@@ -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
+ }
@@ -11,6 +11,7 @@ import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
11
11
  import { parseDbTimestampMs } from "./db-timestamp.js";
12
12
  import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
13
13
  import { evaluateScheduleFailures, failureSummary } from "./scheduled-failures.js";
14
+ import { scheduledTargetOffline, sendOfflineNotice, type OfflineNotice } from "./scheduled-offline.js";
14
15
 
15
16
  export type ScheduleSpec =
16
17
  | { type: "once"; run_at: string }
@@ -366,6 +367,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
366
367
  let finalStatus = "failed";
367
368
  let createdTaskId: string | undefined;
368
369
  const notices: StuckNotice[] = [];
370
+ let offlineNotice: OfflineNotice | null = null;
369
371
  const timedOut: Array<{ taskId: string; fromStatus: string }> = [];
370
372
 
371
373
  db.transaction(() => {
@@ -377,6 +379,35 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
377
379
  [runId, row.schedule_id, row.network_id, scheduledFor],
378
380
  );
379
381
 
382
+ // Board #757 — do not create a task that will merely expire while its
383
+ // exact target session is offline. This branch deliberately precedes the
384
+ // overlap check: target_offline is the actionable reason for this round.
385
+ const targetNode = db.get<{ node_id: string; alias: string | null }>(
386
+ "SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
387
+ row.target_node_id, row.network_id,
388
+ );
389
+ if (targetNode?.alias && scheduledTargetOffline(targetNode.node_id, targetNode.alias, row.network_id, advanceAfter.getTime())) {
390
+ finalStatus = "skipped";
391
+ db.run(
392
+ `UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'target_offline',
393
+ error_message = 'The target node is offline; no task was created', completed_at = datetime('now') WHERE run_id = ?1`,
394
+ [runId],
395
+ );
396
+ const claimed = db.run(
397
+ "UPDATE scheduled_tasks SET offline_alert_at = ?1 WHERE schedule_id = ?2 AND offline_alert_at IS NULL",
398
+ [advanceAfter.toISOString(), row.schedule_id],
399
+ ).changes === 1;
400
+ if (claimed) offlineNotice = {
401
+ scheduleId: row.schedule_id, scheduleName: row.name, networkId: row.network_id,
402
+ createdBy: row.created_by, createdByNodeId: row.created_by_node_id,
403
+ targetAlias: targetNode.alias,
404
+ };
405
+ if (advanceSchedule) advance({ ...row, target_alias: targetNode.alias }, scheduledFor, advanceAfter);
406
+ return;
407
+ }
408
+ // Seeing the target online rearms exactly one warning for a future offline episode.
409
+ if (targetNode?.alias) db.run("UPDATE scheduled_tasks SET offline_alert_at = NULL WHERE schedule_id = ?1 AND offline_alert_at IS NOT NULL", [row.schedule_id]);
410
+
380
411
  if (row.overlap_policy === "skip") {
381
412
  const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
382
413
  // received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
@@ -430,10 +461,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
430
461
  }
431
462
  }
432
463
 
433
- const node = db.get<{ node_id: string; alias: string | null }>(
434
- "SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
435
- row.target_node_id, row.network_id,
436
- );
464
+ const node = targetNode;
437
465
  if (!node?.alias) {
438
466
  finalStatus = "failed";
439
467
  db.run(
@@ -541,6 +569,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
541
569
  try {
542
570
  for (const t of timedOut) logTaskEvent(t.taskId, t.fromStatus, "expired", "hub-scheduler", `stuck timeout; schedule=${row.schedule_id} run=${runId}`);
543
571
  for (const n of notices) sendStuckNotice(n);
572
+ if (offlineNotice) sendOfflineNotice(offlineNotice);
544
573
  // #523 — a run that failed at dispatch counts toward the consecutive-failure alert (task failures arrive via db.ts).
545
574
  if (finalStatus === "failed") evaluateScheduleFailures(row.schedule_id, advanceAfter);
546
575
  if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
package/src/server.ts CHANGED
@@ -4,6 +4,7 @@ import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip"
4
4
  import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
5
5
  import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
6
6
  import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
7
+ import { AGENT_TEAM_SCOPE_DENIED, clearNodeFromAgentTeams, createAgentTeam, deleteAgentTeam, listAgentTeams, ownedScope, setNodeAgentTeam, updateAgentTeam } from "./agent-teams.js";
7
8
  import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
8
9
  import { statusAliasResolverRead } from "./status-alias-resolver.js";
9
10
  import { readNodeHealth } from "./node-health-store.js";
@@ -28,12 +29,13 @@ import { assertNodeActive } from "./lifecycle-guard.js";
28
29
  import { pendingInboxCount } from "./inbox-count.js";
29
30
  import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
30
31
  import { expireStaleOpenTasks } from "./task-stale-open.js";
32
+ import { flagOrphanTasks } from "./task-orphan.js";
31
33
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
32
34
  import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
33
35
  import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
34
36
  import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
35
37
  import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
36
- import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
38
+ import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, newMemberTaskAccess } from "./task-access.js";
37
39
  import { canMessageAgent, isAgentRestricted, visibleAgents, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
38
40
  import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
39
41
  import { validateAvatarUrl } from "./avatar-validate.js";
@@ -976,6 +978,18 @@ export function patrolExpiredTasks(): void {
976
978
  }
977
979
  }
978
980
 
981
+ // #758 — acked/running tasks whose node went idle/offline after starting them
982
+ // and never replied: one task.orphan_suspected event + one sender notice per
983
+ // task (task-orphan.ts). Status untouched. Same 5-minute timer as the TTL patrol.
984
+ export function patrolOrphanTasks(): void {
985
+ try {
986
+ const flagged = flagOrphanTasks();
987
+ if (flagged.length > 0) console.warn(`[patrol] flagged ${flagged.length} orphan-suspected task(s), notified ${flagged.filter((f) => f.notified).length}`);
988
+ } catch (e: any) {
989
+ console.error(`[patrol] orphan phase failed: ${e?.message || e}`);
990
+ }
991
+ }
992
+
979
993
  function patrolDeliveredStaleTasks(): void {
980
994
  try {
981
995
  const result = recordDeliveredStaleEvents();
@@ -1860,6 +1874,48 @@ return Bun.serve({
1860
1874
  return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants, group_grants: result.group_grants }));
1861
1875
  }
1862
1876
 
1877
+ // ── Agent 团队(#764):Agent 自己的组织树,和人的部门树无关。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
1878
+ // 写:owner / admin / Hub 管理员全管;团队 owner_user_id 管自己的子树(规则在 agent-teams.ts);节点令牌只读。 ──
1879
+ const teamMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-teams(?:\/([^/]+))?$/);
1880
+ const nodeTeamMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/nodes\/([^/]+)\/agent-team$/);
1881
+ if (teamMatch || nodeTeamMatch) {
1882
+ const token = requestToken(req, { allowQueryToken: false });
1883
+ const resolved = token ? resolveToken(token) : null;
1884
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1885
+ const netId = decodeURIComponent((teamMatch ?? nodeTeamMatch)![1]);
1886
+ const notMember = () => withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1887
+ if (resolved.networkId && resolved.networkId !== netId) return notMember();
1888
+ const hubAdmin = isHubAdminCredential(resolved);
1889
+ const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
1890
+ if (!hubAdmin && !role && !resolved.networkId) return notMember();
1891
+ if (!db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)) return withCors(req, Response.json({ ok: false, error: "network_not_found" }, { status: 404 }));
1892
+ const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
1893
+ // 只看授权 Agent 的成员:看不见的节点不出现在 members / lead 里(和 Agent 列表同一判据);写路径用同一个判据,看不见 = 404。
1894
+ const seen = !hubAdmin && !resolved.networkId && isAgentRestricted(resolved.user.user_id, netId) ? visibleAgents(resolved.user.user_id, netId) : null;
1895
+ const hidden = (n: { node_id: string; alias: string | null }) => !!seen && !seen.nodeIds.includes(n.node_id) && !(n.alias && seen.aliases.includes(n.alias));
1896
+ if (teamMatch && !teamMatch[2] && req.method === "GET") {
1897
+ return withCors(req, Response.json({ ok: true, network_id: netId, teams: listAgentTeams(netId, hidden) }));
1898
+ }
1899
+ const write = teamMatch ? (teamMatch[2] ? ["PATCH", "DELETE"] : ["POST"]) : ["PUT"];
1900
+ if (!write.includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1901
+ // null = 全管;节点令牌 / 普通成员 → 空集(任何写都 403);团队 owner → 自己的子树。
1902
+ const scope = canManage ? null : resolved.networkId ? new Set<string>() : ownedScope(netId, resolved.user.user_id);
1903
+ if (scope && scope.size === 0) return withCors(req, Response.json({ ok: false, error: AGENT_TEAM_SCOPE_DENIED }, { status: 403 }));
1904
+ let body: Record<string, unknown> = {};
1905
+ if (req.method !== "DELETE") {
1906
+ try { const b = await req.json(); body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1907
+ }
1908
+ const teamId = teamMatch?.[2] ? decodeURIComponent(teamMatch[2]) : "";
1909
+ const nodeId = nodeTeamMatch ? decodeURIComponent(nodeTeamMatch[2]) : undefined;
1910
+ const r = nodeId !== undefined ? setNodeAgentTeam(netId, nodeId, scope, hidden, body.team_id)
1911
+ : req.method === "POST" ? createAgentTeam(netId, resolved.user.user_id, scope, hidden, body)
1912
+ : req.method === "PATCH" ? updateAgentTeam(netId, teamId, scope, hidden, body)
1913
+ : deleteAgentTeam(netId, teamId, scope);
1914
+ if (!r.ok) { const { ok: _ok, status, ...rest } = r; return withCors(req, Response.json({ ok: false, ...rest }, { status })); }
1915
+ logAudit(resolved.user.user_id, resolved.user.username, `agent_team_${req.method.toLowerCase()}`, "network", netId, JSON.stringify({ team_id: teamId || undefined, node_id: nodeId, ...body }).slice(0, 2000), undefined, netId);
1916
+ return withCors(req, Response.json(r, { status: req.method === "POST" ? 201 : 200 }));
1917
+ }
1918
+
1863
1919
  // ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
1864
1920
  const humansMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/humans$/);
1865
1921
  if (humansMatch && req.method === "GET") {
@@ -2178,12 +2234,12 @@ return Bun.serve({
2178
2234
  if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
2179
2235
  return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
2180
2236
  }
2181
- // task_access 缺省 NEW_MEMBER_TASK_ACCESS(RFC-038 §9.4);显式传 'all' / 'scoped' 覆盖。
2237
+ // task_access 缺省 newMemberTaskAccess(role)(RFC-038 §9.4,#746 起 member/viewer 为 'scoped');显式传 'all' / 'scoped' 覆盖。
2182
2238
  if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
2183
2239
  return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
2184
2240
  }
2185
2241
  const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
2186
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? NEW_MEMBER_TASK_ACCESS}`, undefined, netId);
2242
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? newMemberTaskAccess(body.role || "member")}`, undefined, netId);
2187
2243
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
2188
2244
  }
2189
2245
  if (req.method === "PUT" && targetUid) {
@@ -4183,6 +4239,7 @@ return Bun.serve({
4183
4239
 
4184
4240
  db.transaction(() => {
4185
4241
  db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
4242
+ clearNodeFromAgentTeams(node.node_id); // #764
4186
4243
  if (node.alias) {
4187
4244
  db.run(
4188
4245
  "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
@@ -5086,7 +5143,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
5086
5143
  const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
5087
5144
  ? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
5088
5145
  const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
5089
- const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
5146
+ const taskPatrolTimer = setInterval(() => { patrolExpiredTasks(); patrolOrphanTasks(); }, taskPatrolMs);
5090
5147
  patrolDeliveredStaleTasks();
5091
5148
  const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
5092
5149
  const scheduledTaskTimer = startScheduledTaskScheduler();
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 场景:Hub 管理员 admin 的网络 NET。成员:
4
4
  // alice —— 新建成员,经授权接口显式设成 scoped,授权 P1(只看)、P2(可改);
5
- // (新成员默认值 NEW_MEMBER_TASK_ACCESS 暂时是 'all',见「默认值与授权接口」)
5
+ // (#746 起新成员默认值 NEW_MEMBER_TASK_ACCESS 就是 'scoped',见「默认值与授权接口」)
6
6
  // carol —— 升级前的老成员(task_access='all'):行为必须与今天逐字相同;
7
7
  // vic —— 经授权接口显式设成 scoped 的 viewer,授权 P3(只看)。
8
8
  // admin 建的卡:c_own(负责人 alice)、c_part(参与人 alice)、c_p1 / c_p2 / c_p3(在项目里)、c_other(与 alice 无关)。
@@ -117,10 +117,14 @@ afterAll(() => {
117
117
  });
118
118
 
119
119
  describe("默认值与授权接口", () => {
120
- test("新成员默认 all:常量是 'all';管理员建号 / POST members / 邀请码加入都落在 all 且看得见全部卡", async () => {
121
- expect(NEW_MEMBER_TASK_ACCESS).toBe("all");
122
- const all = ["ta-other", "ta-own", "ta-p1", "ta-p2", "ta-p3", "ta-part"];
120
+ test("#746 新成员默认 scoped:管理员建号 / POST members / 邀请码加入都落在 scoped —— 只看自己的卡与授权项目;admin 角色照旧 all", async () => {
121
+ expect(NEW_MEMBER_TASK_ACCESS).toBe("scoped");
123
122
  const stamp = Date.now();
123
+ const names = async (t: string) => {
124
+ const r = await get(t, `/api/requirements?network_id=${NET}`);
125
+ expect(r.status).toBe(200);
126
+ return (r.body.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-") || n.startsWith("dm-")).sort();
127
+ };
124
128
  // 管理员建号
125
129
  const u1 = { username: `ta_new1_${stamp}`, password: PW, network_id: NET, role: "member" };
126
130
  expect((await send(admin.token, "POST", "/api/admin/users", u1)).status).toBe(200);
@@ -128,24 +132,43 @@ describe("默认值与授权接口", () => {
128
132
  // POST /members 不带 task_access
129
133
  const u2 = register(`ta_new2_${stamp}`, PW);
130
134
  expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u2.user!.user_id, role: "member" })).status).toBe(200);
131
- // 邀请码加入
135
+ // 邀请码加入(member 与 viewer 两种邀请)
132
136
  const inv = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "member" });
133
137
  const u3 = register(`ta_new3_${stamp}`, PW);
134
138
  expect((await send(u3.token!, "POST", "/api/networks/join", { invite_code: inv.body.invite_code })).status).toBe(200);
135
- for (const [id, token] of [[l1.body.user.user_id, l1.body.token], [u2.user!.user_id, u2.token!], [u3.user!.user_id, u3.token!]] as [string, string][]) {
139
+ const paths: Array<[string, string, string]> = [["admin-users", l1.body.user.user_id, l1.body.token], ["post-members", u2.user!.user_id, u2.token!], ["invite", u3.user!.user_id, u3.token!]];
140
+ for (const [path, id, token] of paths) {
136
141
  const g = await get(admin.token, `/api/networks/${NET}/members/${id}/task-grants`);
137
- expect(g.body.task_access).toBe("all");
138
- expect(g.body.restricted).toBe(false);
139
- expect(await listIds(token)).toEqual(all);
142
+ expect([path, g.body.task_access, g.body.restricted]).toEqual([path, "scoped", true]);
140
143
  const me = await get(token, "/api/auth/me");
141
- expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("all");
144
+ expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("scoped");
145
+ // 自己负责的卡 + 授权项目 P1 看得见;别人的无关卡(ta-other)列表里没有、直取也是 404
146
+ expect((await send(admin.token, "POST", "/api/requirements", { network_id: NET, name: `dm-own-${path}`, owner: userRef(id) })).status).toBe(201);
147
+ expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${id}/task-grants`, { project_grants: [P["ta-P1"]] })).status).toBe(200);
148
+ expect([path, await names(token)]).toEqual([path, [`dm-own-${path}`, "ta-p1"]]);
149
+ expect((await get(token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`)).status).toBe(404);
150
+ }
151
+ // 管理员仍能经现有接口把成员放宽到 all,之后看见全部卡
152
+ expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${u2.user!.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
153
+ expect((await names(u2.token!))).toContain("ta-other");
154
+ // admin 角色(POST members / 邀请码)照旧存 'all';不受这一列约束
155
+ const a1 = register(`ta_new5_${stamp}`, PW);
156
+ expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: a1.user!.user_id, role: "admin" })).status).toBe(200);
157
+ const invA = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "admin" });
158
+ const a2 = register(`ta_new6_${stamp}`, PW);
159
+ expect((await send(a2.token!, "POST", "/api/networks/join", { invite_code: invA.body.invite_code })).status).toBe(200);
160
+ for (const a of [a1, a2]) {
161
+ const g = await get(admin.token, `/api/networks/${NET}/members/${a.user!.user_id}/task-grants`);
162
+ expect([g.body.task_access, g.body.restricted]).toEqual(["all", false]);
163
+ expect(await names(a.token!)).toContain("ta-other");
142
164
  }
143
- // POST /members 显式传 scoped 仍然生效
165
+ // 升级前的老成员(carol,直接写 'all' 的行)照旧全看见
166
+ expect(await names(carol.token)).toContain("ta-other");
167
+ // POST /members 显式传 all 仍然生效
144
168
  const u4 = register(`ta_new4_${stamp}`, PW);
145
- expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "scoped" })).status).toBe(200);
146
- expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("scoped");
147
- expect(await listIds(u4.token!)).toEqual([]);
148
- for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id]) {
169
+ expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "all" })).status).toBe(200);
170
+ expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("all");
171
+ for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id, a1.user!.user_id, a2.user!.user_id]) {
149
172
  expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${id}`)).status).toBe(200);
150
173
  }
151
174
  });
@@ -17,7 +17,7 @@ import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsF
17
17
 
18
18
  // ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
19
19
  // ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
20
- // NEW_MEMBER_TASK_ACCESS 入库。项目授权:行存在 = 可看,can_edit=1 = 可改。
20
+ // newMemberTaskAccess() 入库(#746:默认 'scoped')。项目授权:行存在 = 可看,can_edit=1 = 可改。
21
21
  try { db.exec("ALTER TABLE network_members ADD COLUMN task_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
22
22
  db.exec(`
23
23
  CREATE TABLE IF NOT EXISTS network_member_project_grants (
@@ -33,12 +33,19 @@ db.exec(`
33
33
  `);
34
34
 
35
35
  /**
36
- * 新成员(管理员建号 / POST members / 邀请码)默认的任务范围。
37
- * 暂时是 'all':app ≤0.2.163 没有任务权限界面,从那些 app 加的成员若默认 scoped 会一张卡都看不到,
38
- * 管理员也没法在那些 app 里放宽。等 owner 的设备都 ≥0.2.164 且他确认后,改回 'scoped'(只改这一行)。
39
- * 显式 task_access='scoped'(POST members / PUT task-grants)不受这个默认值影响。
36
+ * 新成员默认的任务范围(#746,owner 选方案 2):只看相关任务。入库路径全部显式写这一列,不靠列 DEFAULT:
37
+ * ① addNetworkMember —— POST /api/networks/:id/members、POST /api/admin/users(带 network_id)
38
+ * ② joinByInvite —— POST /api/networks/join(邀请码)
39
+ * owner 行(register 自动建网、createNetwork、db.ts 的 V3.13 迁移)不经过这里,照旧落列默认 'all'。
40
+ * 列 DEFAULT 仍是 'all'、不迁移老行 —— 升级前的成员一个都不收窄。显式 task_access 覆盖默认;
41
+ * 管理员随时可 PUT /api/networks/:id/members/:uid/task-grants {task_access:'all'} 放宽。
40
42
  */
41
- export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "all";
43
+ export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "scoped";
44
+
45
+ /** 新成员行要写入的 task_access。admin 角色本来就不受这一列约束,照旧存 'all'(降级成 member 时与今天行为一致)。 */
46
+ export function newMemberTaskAccess(role: string): TaskAccessMode {
47
+ return UNRESTRICTED_ROLES.has(role) ? "all" : NEW_MEMBER_TASK_ACCESS;
48
+ }
42
49
 
43
50
  export type TaskAccessMode = "all" | "scoped";
44
51
  export type ProjectGrant = { project_id: string; can_edit: boolean };
@@ -201,31 +201,33 @@ function noteEvent(taskId: string, status: string, detail: string): void {
201
201
  } catch {}
202
202
  }
203
203
 
204
- function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown>): string {
205
- const id = `exp_${uuidv4().replace(/-/g, "").slice(0, 20)}`;
204
+ function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown>, status = "expired", prefix = "exp_"): string {
205
+ const id = `${prefix}${uuidv4().replace(/-/g, "").slice(0, 20)}`;
206
206
  const nodeId = agentSession(alias, networkId)?.node_id ?? null;
207
207
  db.run(
208
208
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
209
209
  VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, 'hub', ?5, 'none', ?6, ?7)`,
210
210
  [id, alias, nodeId, text, inReplyTo, networkId, JSON.stringify(meta)],
211
211
  );
212
- pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status: "expired" }, networkId);
212
+ pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status }, networkId);
213
213
  return id;
214
214
  }
215
215
 
216
- function deliver(
216
+ /** 按 route 投递一条通知。#758 孤儿任务通知复用它(kind / status / id 前缀不同)。 */
217
+ export function deliver(
217
218
  route: Extract<ExpirySenderRoute, { kind: "agent" | "user" }>,
218
219
  networkId: string | null,
219
220
  fromAlias: string,
220
221
  inReplyTo: string,
221
222
  notice: { title: string; text: string },
222
223
  meta: Record<string, unknown>,
224
+ as: { kind: string; status: string; agentPrefix: string; userPrefix: string } = { kind: TASK_EXPIRED_NOTICE_KIND, status: "expired", agentPrefix: "exp_", userPrefix: "dm_expired_" },
223
225
  ): string | null {
224
- if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta);
226
+ if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta, as.status, as.agentPrefix);
225
227
  if (!networkId) return null;
226
228
  return sendAgentNotice({
227
- networkId, userId: route.userId, fromAlias, kind: TASK_EXPIRED_NOTICE_KIND,
228
- title: notice.title, text: notice.text, severity: "warning", meta, idPrefix: "dm_expired_",
229
+ networkId, userId: route.userId, fromAlias, kind: as.kind,
230
+ title: notice.title, text: notice.text, severity: "warning", meta, idPrefix: as.userPrefix,
229
231
  });
230
232
  }
231
233