@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.
|
|
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",
|
package/src/node-permissions.ts
CHANGED
|
@@ -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;
|
package/src/schedule-agent.ts
CHANGED
|
@@ -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";
|
package/src/tool-audience.ts
CHANGED
|
@@ -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 },
|