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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.117",
3
+ "version": "0.9.0-preview.118",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -271,6 +271,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
271
271
  // 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
272
272
  schedule_list: "read", schedule_get: "read", schedule_runs: "read",
273
273
  schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
274
+ schedule_batch_interval: "schedule",
274
275
  // 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
275
276
  update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
276
277
  stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
@@ -161,6 +161,72 @@ describe("happy path from a node", () => {
161
161
  });
162
162
  });
163
163
 
164
+ describe("batch interval updates (#816)", () => {
165
+ const batch = (token: string, schedule_ids: string[], every_seconds = 600) =>
166
+ mcp(token, "schedule_batch_interval", { schedule_ids, every_seconds });
167
+ const get = async (id: string) => (await mcp(node.b.token, "schedule_get", { schedule_id: id })).schedule;
168
+
169
+ test("two updates, deduplicated IDs, paused stays paused, unselected stays untouched", async () => {
170
+ const a = (await create("b")).schedule, b = (await create("b")).schedule;
171
+ const untouched = (await create("b")).schedule;
172
+ await mcp(node.b.token, "schedule_update", { schedule_id: b.schedule_id, status: "paused" });
173
+ const before = Date.now();
174
+ const result = await batch(node.b.token, [a.schedule_id, b.schedule_id, a.schedule_id]);
175
+ expect(result).toMatchObject({ ok: true, updated: 2, failed: 0 });
176
+ expect(result.results).toHaveLength(2);
177
+ expect(result.results[0]).toMatchObject({ schedule_id: a.schedule_id, every_seconds: 600, revision: a.revision + 1, status: "active" });
178
+ expect(Date.parse(result.results[0].next_run_at)).toBeGreaterThanOrEqual(before + 600_000);
179
+ expect(result.results[1]).toMatchObject({ status: "paused", next_run_at: null, every_seconds: 600 });
180
+ for (const original of [a, b]) {
181
+ expect(await get(original.schedule_id)).toMatchObject({ name: original.name, task_content: original.task_content, target_node_id: original.target_node_id, schedule: { type: "interval", every_seconds: 600 } });
182
+ }
183
+ expect(await get(untouched.schedule_id)).toEqual(untouched);
184
+ });
185
+
186
+ test("partial results preserve human, foreign-network, daily and cancelled schedules", async () => {
187
+ const own = (await create("b")).schedule;
188
+ const daily = (await create("b", { schedule: { type: "daily", time: "09:30" } })).schedule;
189
+ const cancelled = (await create("b")).schedule;
190
+ await mcp(node.b.token, "schedule_cancel", { schedule_id: cancelled.schedule_id });
191
+ const human = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "human batch", task: "t", target_node_id: node.b.id, schedule: EVERY_MIN });
192
+ const foreign = (await create("x")).schedule;
193
+ const ids = [human.body.schedule.schedule_id, foreign.schedule_id, "no-such-schedule", daily.schedule_id, cancelled.schedule_id, own.schedule_id];
194
+ const result = await batch(node.b.token, ids);
195
+ expect(result).toMatchObject({ ok: false, updated: 1, failed: 5 });
196
+ expect(result.results.map((r: any) => r.error ?? "ok")).toEqual(["not_schedule_creator", "schedule_not_found", "schedule_not_found", "not_interval_schedule", "schedule_cancelled", "ok"]);
197
+ expect((await get(ids[0])).schedule).toEqual(EVERY_MIN);
198
+ expect((await mcp(node.x.token, "schedule_get", { schedule_id: foreign.schedule_id })).schedule).toEqual(foreign);
199
+ expect(await get(daily.schedule_id)).toEqual(daily);
200
+ expect((await get(cancelled.schedule_id)).status).toBe("cancelled");
201
+ });
202
+
203
+ test("invalid interval or batch shape performs no writes; node-only and readonly gates remain", async () => {
204
+ const own = (await create("b")).schedule, ids = [own.schedule_id];
205
+ for (const seconds of [59, 60.5, 365 * 86400 + 1]) {
206
+ expect(await batch(node.b.token, ids, seconds)).toMatchObject({ ok: false, error: "invalid_interval" });
207
+ }
208
+ for (const invalid of [[], Array(101).fill(own.schedule_id), [""]]) {
209
+ expect((await batch(node.b.token, invalid)).ok).not.toBe(true);
210
+ }
211
+ expect((await batch(user.boss.token, ids)).error).toBe("network_token_required");
212
+ expect(await batch(node.ro.token, ids)).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
213
+ expect(await get(own.schedule_id)).toEqual(own);
214
+ });
215
+
216
+ test("creator still needs permission to reach the current target", async () => {
217
+ const own = (await create("b", { target_node_id: node.a.id })).schedule;
218
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.b.id]);
219
+ try {
220
+ const result = await batch(node.b.token, [own.schedule_id]);
221
+ expect(result).toMatchObject({ ok: false, updated: 0, failed: 1 });
222
+ expect(result.results[0]).toMatchObject({ reason: "agent_not_granted_to_owner" });
223
+ expect(await get(own.schedule_id)).toEqual(own);
224
+ } finally {
225
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.boss.id, node.b.id]);
226
+ }
227
+ });
228
+ });
229
+
164
230
  describe("denials", () => {
165
231
  test("another network: cannot target, see or touch it", async () => {
166
232
  const mine = (await create("a")).schedule.schedule_id;
@@ -23,7 +23,7 @@
23
23
  import { db } from "./db.js";
24
24
  import {
25
25
  cancelSchedule, decodeRowForAgent, dispatchScheduledOccurrence, getScheduleRow, insertSchedule, patchSchedule,
26
- scheduleRuns, ScheduleInputError, validateScheduleCreate, type ScheduledRow,
26
+ scheduleRuns, ScheduleInputError, validateScheduleCreate, parseScheduleSpec, type ScheduledRow,
27
27
  } from "./scheduled-tasks.js";
28
28
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
29
29
  import { dispatchVerdict, nodePermissionDeniedBody, recordNodePermission, type NodeIdentity } from "./node-permissions.js";
@@ -145,6 +145,25 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
145
145
  } catch (e) { return inputError(e); }
146
146
  }
147
147
 
148
+ /** #816: partial success, one result per distinct ID; no change to existing write authority. */
149
+ export function agentBatchScheduleInterval(id: NodeIdentity, canWrite: boolean, scheduleIds: string[], everySeconds: number): AgentScheduleResult {
150
+ const gate = writeGate(id, canWrite, "mcp:schedule_batch_interval");
151
+ if (gate) return gate;
152
+ const schedule = { type: "interval", every_seconds: everySeconds };
153
+ try { parseScheduleSpec(schedule, "UTC"); } catch (e) { return inputError(e); }
154
+ const results = [...new Set(scheduleIds)].map(schedule_id => {
155
+ const row = ownRow(id, schedule_id);
156
+ if (!isRow(row)) return { schedule_id, ...row };
157
+ if (row.schedule_type !== "interval") return { schedule_id, ok: false, error: "not_interval_schedule" };
158
+ const result = agentUpdateSchedule(id, canWrite, schedule_id, { schedule, base_revision: row.revision });
159
+ if (!result.ok) return { schedule_id, ...result };
160
+ const updated = result.schedule as ReturnType<typeof decodeRowForAgent>;
161
+ return { schedule_id, ok: true, every_seconds: everySeconds, next_run_at: updated.next_run_at, status: updated.status, revision: updated.revision };
162
+ });
163
+ const updated = results.filter(r => r.ok).length;
164
+ return { ok: updated === results.length, updated, failed: results.length - updated, results };
165
+ }
166
+
148
167
  export function agentCancelSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
149
168
  const gate = writeGate(id, canWrite, "mcp:schedule_cancel");
150
169
  if (gate) return gate;
@@ -43,6 +43,7 @@ const OVERRIDES: Record<string, Record<string, unknown>> = {
43
43
  mark_tasks_consumed: { task_ids: ["task_probe"] },
44
44
  upsert_network_secret: { key: "PROBE_KEY" },
45
45
  update_provider: { provider_id: "prov_probe" },
46
+ schedule_batch_interval: { schedule_ids: ["sched_probe"], every_seconds: 60 },
46
47
  };
47
48
  function fill(schema: any, key: string, ctx: Record<string, unknown>): unknown {
48
49
  if (!schema) return "probe";
@@ -29,7 +29,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
29
29
  report_status: "node", org_whoami: "node",
30
30
  // #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
31
31
  schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
32
- schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
32
+ schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node", schedule_batch_interval: "node",
33
33
  // ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
34
34
  mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
35
35
  get_config_update: "protocol", ack_config_update: "protocol",
package/src/tools.ts CHANGED
@@ -21,7 +21,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
21
21
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
22
22
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
23
23
  import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
24
- import { agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
24
+ import { agentBatchScheduleInterval, agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
25
25
  import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
26
26
  import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
27
27
  import { errorBody } from "./requirements-errors.js";
@@ -6396,6 +6396,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6396
6396
  server.tool("schedule_update", "Edit your schedule; status=paused|active.",
6397
6397
  { ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
6398
6398
  async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
6399
+ server.tool("schedule_batch_interval", "Batch interval edit; per-ID results.",
6400
+ { schedule_ids: z.array(z.string()).min(1).max(100), every_seconds: z.number() },
6401
+ async ({ schedule_ids, every_seconds }) => scheduleCall((id, w) => agentBatchScheduleInterval(id, w, schedule_ids, every_seconds)));
6399
6402
  server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
6400
6403
  async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
6401
6404
  server.tool("schedule_run_now", "Run your schedule now.", { schedule_id: schedId },