@sleep2agi/commhub-server 0.9.0-preview.113 → 0.9.0-preview.115
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 +1 -1
- package/src/auth.ts +1 -1
- package/src/create-node-unicode-name.test.ts +2 -0
- package/src/db.ts +29 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-permissions.ts +4 -1
- package/src/node-token-privilege-http.test.ts +9 -0
- package/src/rename.ts +15 -0
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -1
- package/src/schedule-agent-mcp-http.test.ts +315 -0
- package/src/schedule-agent.ts +173 -0
- package/src/scheduled-tasks.ts +174 -89
- package/src/server.ts +18 -3
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/tool-audience-http.test.ts +8 -1
- package/src/tool-audience.ts +3 -0
- package/src/tools.ts +161 -8
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { DbAdapter } from "./db-adapter";
|
|
2
|
+
|
|
3
|
+
type StoredLateReply = {
|
|
4
|
+
late_reply_id: string;
|
|
5
|
+
task_id: string;
|
|
6
|
+
from_node_id: string;
|
|
7
|
+
thread_id: string;
|
|
8
|
+
turn_id: string;
|
|
9
|
+
status: "replied" | "failed" | "cancelled";
|
|
10
|
+
result: string;
|
|
11
|
+
meta_json: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parsedMeta(raw: string | null): Record<string, unknown> | null {
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(raw);
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? value as Record<string, unknown>
|
|
21
|
+
: null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stable detail projection. Storage routing ids stay private. */
|
|
28
|
+
export function listTaskLateReplies(database: DbAdapter, taskId: string, networkId: string | null) {
|
|
29
|
+
const params: unknown[] = [taskId];
|
|
30
|
+
let sql = `SELECT late_reply_id,task_id,from_node_id,thread_id,turn_id,status,result,meta_json,created_at
|
|
31
|
+
FROM task_late_replies WHERE task_id=?1`;
|
|
32
|
+
if (networkId) {
|
|
33
|
+
params.push(networkId);
|
|
34
|
+
sql += " AND network_id=?2";
|
|
35
|
+
} else {
|
|
36
|
+
sql += " AND network_id IS NULL";
|
|
37
|
+
}
|
|
38
|
+
sql += " ORDER BY created_at,late_reply_id";
|
|
39
|
+
return database.all<StoredLateReply>(sql, ...params).map((row) => {
|
|
40
|
+
const meta = parsedMeta(row.meta_json);
|
|
41
|
+
return {
|
|
42
|
+
late_reply_id: row.late_reply_id,
|
|
43
|
+
task_id: row.task_id,
|
|
44
|
+
from_node_id: row.from_node_id,
|
|
45
|
+
thread_id: row.thread_id,
|
|
46
|
+
turn_id: row.turn_id,
|
|
47
|
+
status: row.status,
|
|
48
|
+
result: row.result,
|
|
49
|
+
created_at: row.created_at,
|
|
50
|
+
late: true as const,
|
|
51
|
+
...(meta ? { meta } : {}),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/late710-")}/hub.db`;
|
|
5
|
+
|
|
6
|
+
let db: any;
|
|
7
|
+
let hub: ReturnType<typeof Bun.serve>;
|
|
8
|
+
let base = "";
|
|
9
|
+
let owner: any;
|
|
10
|
+
let outsider: any;
|
|
11
|
+
let workerToken = "";
|
|
12
|
+
let siblingToken = "";
|
|
13
|
+
const workerId = "n_late710_worker";
|
|
14
|
+
const siblingId = "n_late710_sibling";
|
|
15
|
+
const taskId = "task_late710_terminal";
|
|
16
|
+
const threadId = "thread_late710_exact";
|
|
17
|
+
const turnId = "turn_late710_exact";
|
|
18
|
+
|
|
19
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>) {
|
|
20
|
+
const response = await fetch(`${base}/mcp`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${token}`,
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
Accept: "application/json, text/event-stream",
|
|
26
|
+
"MCP-Protocol-Version": "2025-03-26",
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
29
|
+
});
|
|
30
|
+
expect(response.status).toBe(200);
|
|
31
|
+
const raw = await response.text();
|
|
32
|
+
const events = raw.split("\n").filter((line) => line.startsWith("data:"));
|
|
33
|
+
const envelope = JSON.parse(events.length ? events.at(-1)!.slice(5) : raw);
|
|
34
|
+
expect(envelope.error).toBeUndefined();
|
|
35
|
+
return JSON.parse(envelope.result.content[0].text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function get(token: string, path: string) {
|
|
39
|
+
const response = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
40
|
+
return { status: response.status, body: await response.json() as any };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
({ db } = await import("./db.js"));
|
|
45
|
+
const { register } = await import("./auth.js");
|
|
46
|
+
const { generateNetworkToken, hashToken } = await import("./db.js");
|
|
47
|
+
owner = register("late710_owner", "Fixture-Strong1!");
|
|
48
|
+
outsider = register("late710_outside", "Fixture-Strong1!");
|
|
49
|
+
expect(owner.ok && outsider.ok).toBe(true);
|
|
50
|
+
|
|
51
|
+
const addNode = (nodeId: string, alias: string) => {
|
|
52
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,lifecycle_state)
|
|
53
|
+
VALUES(?1,?2,?2,?3,?4,'active')`, [nodeId, alias, owner.network_id, owner.user.user_id]);
|
|
54
|
+
db.run(`INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at)
|
|
55
|
+
VALUES(?1,?2,?1,?3,'idle',datetime('now'))`, [nodeId, alias, owner.network_id]);
|
|
56
|
+
const raw = generateNetworkToken();
|
|
57
|
+
db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,bound_node_id,node_identity_epoch)
|
|
58
|
+
VALUES(?1,?2,?3,'network',?4,?5,?1,2)`, [nodeId, owner.user.user_id, owner.network_id, `node:${alias}`, hashToken(raw)]);
|
|
59
|
+
return raw;
|
|
60
|
+
};
|
|
61
|
+
workerToken = addNode(workerId, "late710-worker");
|
|
62
|
+
siblingToken = addNode(siblingId, "late710-sibling");
|
|
63
|
+
db.run(`INSERT INTO tasks
|
|
64
|
+
(task_id,from_name,to_name,to_node_id,status,content,result,network_id,thread_id,turn_id,completed_at)
|
|
65
|
+
VALUES(?1,?2,'late710-worker',?3,'failed','original task','watchdog interrupted',?4,?5,?6,datetime('now'))`,
|
|
66
|
+
[taskId, owner.user.username, workerId, owner.network_id, threadId, turnId]);
|
|
67
|
+
|
|
68
|
+
const { bootServer } = await import("./server.js");
|
|
69
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
70
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
71
|
+
}, 60_000);
|
|
72
|
+
|
|
73
|
+
afterAll(() => hub?.stop(true));
|
|
74
|
+
|
|
75
|
+
test("exact executor and exact turn append one late receipt without rewriting terminal task", async () => {
|
|
76
|
+
const before = db.get("SELECT status,result,completed_at FROM tasks WHERE task_id=?1", taskId);
|
|
77
|
+
const accepted = await mcp(workerToken, "send_reply", {
|
|
78
|
+
in_reply_to: taskId,
|
|
79
|
+
text: "recovered final result",
|
|
80
|
+
status: "replied",
|
|
81
|
+
thread_id: threadId,
|
|
82
|
+
turn_id: turnId,
|
|
83
|
+
});
|
|
84
|
+
expect(accepted).toMatchObject({ ok: true, late: true, duplicate: false });
|
|
85
|
+
expect(db.get("SELECT status,result,completed_at FROM tasks WHERE task_id=?1", taskId)).toEqual(before);
|
|
86
|
+
expect(db.get("SELECT COUNT(*) AS n FROM task_events WHERE task_id=?1", taskId).n).toBe(0);
|
|
87
|
+
|
|
88
|
+
const detail = await get(owner.token, `/api/tasks/${taskId}`);
|
|
89
|
+
expect(detail.status).toBe(200);
|
|
90
|
+
expect(detail.body.task.late_replies).toEqual([expect.objectContaining({
|
|
91
|
+
late: true, task_id: taskId, from_node_id: workerId,
|
|
92
|
+
thread_id: threadId, turn_id: turnId, status: "replied", result: "recovered final result",
|
|
93
|
+
})]);
|
|
94
|
+
const legacyDetail = await get(owner.token, `/api/tasks?task_id=${taskId}&limit=1&network_id=${owner.network_id}`);
|
|
95
|
+
expect(legacyDetail.body.tasks[0].late_replies).toEqual(detail.body.task.late_replies);
|
|
96
|
+
const mcpDetail = await mcp(owner.token, "get_task", { task_id: taskId });
|
|
97
|
+
expect(mcpDetail.task.late_replies).toEqual(detail.body.task.late_replies);
|
|
98
|
+
|
|
99
|
+
const messages = await get(owner.token, `/api/messages?alias=${encodeURIComponent(owner.user.username)}&since=2000-01-01%2000:00:00`);
|
|
100
|
+
expect(messages.body.messages).toContainEqual(expect.objectContaining({ id: accepted.message_id, late: true, content: "recovered final result" }));
|
|
101
|
+
const inbox = await mcp(owner.token, "get_inbox", { alias: owner.user.username, limit: 100 });
|
|
102
|
+
expect(inbox.messages).toContainEqual(expect.objectContaining({ id: accepted.message_id, late: true, content: "recovered final result" }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("exact retry is idempotent and a changed payload conflicts", async () => {
|
|
106
|
+
const retry = await mcp(workerToken, "send_reply", {
|
|
107
|
+
in_reply_to: taskId, text: "recovered final result", status: "replied", thread_id: threadId, turn_id: turnId,
|
|
108
|
+
});
|
|
109
|
+
expect(retry).toMatchObject({ ok: true, late: true, duplicate: true });
|
|
110
|
+
expect(db.get("SELECT COUNT(*) AS n FROM task_late_replies WHERE task_id=?1", taskId).n).toBe(1);
|
|
111
|
+
expect(db.get("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to=?1 AND content='recovered final result'", taskId).n).toBe(1);
|
|
112
|
+
expect(await mcp(workerToken, "send_reply", {
|
|
113
|
+
in_reply_to: taskId, text: "different result", status: "replied", thread_id: threadId, turn_id: turnId,
|
|
114
|
+
})).toMatchObject({ ok: false, error: "late_reply_conflict" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("wrong executor, sibling turn, cross-network reader and legacy shape cannot append", async () => {
|
|
118
|
+
const count = () => db.get("SELECT COUNT(*) AS n FROM task_late_replies").n;
|
|
119
|
+
const before = count();
|
|
120
|
+
expect(await mcp(siblingToken, "send_reply", {
|
|
121
|
+
in_reply_to: taskId, text: "impostor", thread_id: threadId, turn_id: turnId,
|
|
122
|
+
})).toMatchObject({ ok: false, error: "late_reply_task_not_owned" });
|
|
123
|
+
expect(await mcp(workerToken, "send_reply", {
|
|
124
|
+
in_reply_to: taskId, text: "wrong turn", thread_id: threadId, turn_id: "turn_late710_sibling",
|
|
125
|
+
})).toMatchObject({ ok: false, error: "late_reply_context_mismatch" });
|
|
126
|
+
expect(await mcp(workerToken, "send_reply", {
|
|
127
|
+
in_reply_to: taskId, text: "legacy terminal reply",
|
|
128
|
+
})).toMatchObject({ ok: false, error: "reply_task_terminal" });
|
|
129
|
+
expect(await mcp(owner.token, "send_reply", {
|
|
130
|
+
in_reply_to: taskId, text: "human cannot forge runtime evidence", thread_id: threadId, turn_id: turnId,
|
|
131
|
+
})).toMatchObject({ ok: false, error: "late_reply_node_token_required" });
|
|
132
|
+
// Length bound is enforced in the handler (kept out of tools/list for #476).
|
|
133
|
+
for (const bad of ["", "x".repeat(201)]) {
|
|
134
|
+
expect(await mcp(workerToken, "send_reply", {
|
|
135
|
+
in_reply_to: taskId, text: "bad identity", thread_id: bad, turn_id: turnId,
|
|
136
|
+
})).toMatchObject({ ok: false, error: "late_reply_context_invalid" });
|
|
137
|
+
}
|
|
138
|
+
expect(count()).toBe(before);
|
|
139
|
+
expect((await get(outsider.token, `/api/tasks/${taskId}`)).status).toBe(404);
|
|
140
|
+
expect((await mcp(outsider.token, "get_task", { task_id: taskId })).ok).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("nonterminal legacy reply retains its established behavior", async () => {
|
|
144
|
+
const openId = "task_late710_open";
|
|
145
|
+
db.run(`INSERT INTO tasks(task_id,from_name,to_name,to_node_id,status,content,network_id)
|
|
146
|
+
VALUES(?1,?2,'late710-worker',?3,'running','legacy open',?4)`, [openId, owner.user.username, workerId, owner.network_id]);
|
|
147
|
+
const result = await mcp(workerToken, "send_reply", { in_reply_to: openId, text: "ordinary final" });
|
|
148
|
+
expect(result).toMatchObject({ ok: true });
|
|
149
|
+
expect(result.late).toBeUndefined();
|
|
150
|
+
expect(db.get("SELECT status,result FROM tasks WHERE task_id=?1", openId)).toEqual({ status: "replied", result: "ordinary final" });
|
|
151
|
+
expect(db.get("SELECT COUNT(*) AS n FROM task_late_replies WHERE task_id=?1", openId).n).toBe(0);
|
|
152
|
+
});
|
|
@@ -184,7 +184,14 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
184
184
|
// 量出来(#478 合入时):全部 74 个工具 71,559 B;节点 52 个 57,632 B(−19%);人 56 个 54,780 B(−23%)。留 ~1.5 KB 余量。
|
|
185
185
|
// Board #625 adds 2 human tools + 2 hidden daemon protocol tools. Measured:
|
|
186
186
|
// all 78 / 73,969 B; user 58 / 56,615 B; node remains 52 / 58,320 B.
|
|
187
|
-
|
|
187
|
+
// Board #733 adds 7 node-only schedule_* tools (user list unchanged). Measured before → after on the same run:
|
|
188
|
+
// node 52 / 58,836 B → 59 / 61,607 B (+2,771 B, ~396 B per tool of which ~180 B is SDK per-tool overhead —
|
|
189
|
+
// $schema + execution — after trimming schemas to bare field names; values are validated server-side);
|
|
190
|
+
// all 78 / 74,485 B → 85 / 77,256 B; user 58 / 56,834 B → 58 / 56,834 B. main had 164 B node headroom left,
|
|
191
|
+
// so any new node tool needs this; ceilings keep the same ~400–750 B margin.
|
|
192
|
+
// Review follow-up: descriptions / schemas cut further → node 59 / 61,100 B, all 85 / 76,749 B (−507 B each),
|
|
193
|
+
// leaving ≥ 700 B node margin even with #2488 (+116 B) merged.
|
|
194
|
+
const CEILINGS = { node: 62_000, user: 57_500, all: 78_000 } as const;
|
|
188
195
|
test("node / user / all", async () => {
|
|
189
196
|
const node = await listTools(T.node_normal);
|
|
190
197
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -27,6 +27,9 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
27
27
|
get_adopt_request: "protocol", ack_adopt_request: "protocol",
|
|
28
28
|
// ── 节点:用户令牌一律 network_token_required ──
|
|
29
29
|
report_status: "node",
|
|
30
|
+
// #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
|
|
31
|
+
schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
|
|
32
|
+
schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
|
|
30
33
|
// ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
|
|
31
34
|
mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
|
|
32
35
|
get_config_update: "protocol", ack_config_update: "protocol",
|
package/src/tools.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
|
20
20
|
import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
21
21
|
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
22
22
|
import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
|
|
23
|
+
import { agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
|
|
23
24
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
24
25
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
25
26
|
import { errorBody } from "./requirements-errors.js";
|
|
@@ -82,6 +83,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
|
|
|
82
83
|
import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
|
|
83
84
|
import { parseHubTimestamp } from "./hub-timestamp";
|
|
84
85
|
import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
86
|
+
import { listTaskLateReplies } from "./task-late-replies.js";
|
|
85
87
|
|
|
86
88
|
function ts(): string {
|
|
87
89
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -102,6 +104,15 @@ function normalizeMetaJson(meta: unknown): string | null {
|
|
|
102
104
|
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
function lateReplyContextMatches(
|
|
108
|
+
storedThreadId: string | null,
|
|
109
|
+
storedTurnId: string | null,
|
|
110
|
+
threadId: string,
|
|
111
|
+
turnId: string,
|
|
112
|
+
): boolean {
|
|
113
|
+
return storedThreadId === threadId && storedTurnId === turnId;
|
|
114
|
+
}
|
|
115
|
+
|
|
105
116
|
/**
|
|
106
117
|
* 定时任务的回复收件人。scheduler 派的任务 from_name='scheduler',回复按 from_name 落进
|
|
107
118
|
* inbox(session_name='scheduler')—— 没有任何人读那一行,于是建排程的人在 app 里看得到回复
|
|
@@ -109,7 +120,7 @@ function normalizeMetaJson(meta: unknown): string | null {
|
|
|
109
120
|
* 排程是「建它的人」的委托(见 scheduled-tasks.ts canMessageAgent),回复送到他的用户名下。
|
|
110
121
|
*
|
|
111
122
|
* 退回 null(= 照旧送 'scheduler')的情形:任务不是 scheduler 派的 / meta 没有排程 id /
|
|
112
|
-
* 排程没有 created_by(外部或旧排程)/ 用户已不存在 / 用户名与本网络某个节点 alias 撞名
|
|
123
|
+
* 排程没有 created_by(外部或旧排程)/ 排程是 Agent 建的(#733 created_by_node_id,不往主人的未读里灌)/ 用户已不存在 / 用户名与本网络某个节点 alias 撞名
|
|
113
124
|
* (那一行会变成那个节点的待办,不是用户的未读 —— 与 server.ts userInboxAliasCollides 同一条规则)。
|
|
114
125
|
* 只改投递地址;任务归属、reply_target_mismatch 等校验仍按原始发送方 'scheduler' 判。
|
|
115
126
|
*/
|
|
@@ -124,7 +135,7 @@ function scheduledReplyRecipient(taskId: string, networkId: string | null | unde
|
|
|
124
135
|
if (!scheduleId) return null;
|
|
125
136
|
const owner = db.get<{ username: string | null }>(
|
|
126
137
|
`SELECT u.username AS username FROM scheduled_tasks s JOIN users u ON u.user_id = s.created_by
|
|
127
|
-
WHERE s.schedule_id = ?1 AND s.network_id = ?2`,
|
|
138
|
+
WHERE s.schedule_id = ?1 AND s.network_id = ?2 AND s.created_by_node_id IS NULL`,
|
|
128
139
|
scheduleId, task.network_id,
|
|
129
140
|
);
|
|
130
141
|
const username = owner?.username?.trim();
|
|
@@ -248,7 +259,7 @@ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId:
|
|
|
248
259
|
};
|
|
249
260
|
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
250
261
|
const cls = NODE_TOOL_CLASS[name];
|
|
251
|
-
if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
|
|
262
|
+
if (cls && cls !== "always" && cls !== "read" && cls !== "requirements" && cls !== "schedule") {
|
|
252
263
|
const id = nodeIdentity(tokenId, networkId);
|
|
253
264
|
const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
|
|
254
265
|
const verdict = id ? verdictFor(id, name, args) : null;
|
|
@@ -1404,16 +1415,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1404
1415
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
1405
1416
|
const rowsParams: any[] = [alias];
|
|
1406
1417
|
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
|
|
1418
|
+
CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late,
|
|
1407
1419
|
CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
|
|
1408
1420
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
1409
1421
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
1410
1422
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
1411
1423
|
LIMIT ?${rowsParams.length + 1}`;
|
|
1412
1424
|
rowsParams.push(limit);
|
|
1413
|
-
const rows = db.all(rowsSql, ...rowsParams).map((row: any) =>
|
|
1414
|
-
...row
|
|
1415
|
-
|
|
1416
|
-
|
|
1425
|
+
const rows = db.all(rowsSql, ...rowsParams).map((row: any) => {
|
|
1426
|
+
const { late, ...rest } = row;
|
|
1427
|
+
const isLate = late === 1 || late === true;
|
|
1428
|
+
return { ...rest, ...(isLate ? { late: true } : {}), meta: parseMetaJson(row.meta_json) };
|
|
1429
|
+
});
|
|
1417
1430
|
|
|
1418
1431
|
return {
|
|
1419
1432
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -2132,15 +2145,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2132
2145
|
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
2133
2146
|
// top-level wins (same rule as REST /api/task L2101).
|
|
2134
2147
|
meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
|
|
2148
|
+
// Exact late-receipt identity. Keep the tools/list schema terse (#476
|
|
2149
|
+
// byte ceiling): the 1..200 length bound is enforced in handleReply,
|
|
2150
|
+
// not advertised; the full contract lives in the task API docs.
|
|
2151
|
+
thread_id: z.string().optional(),
|
|
2152
|
+
turn_id: z.string().optional(),
|
|
2135
2153
|
};
|
|
2136
2154
|
const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
|
|
2137
|
-
const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
|
|
2155
|
+
const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta, thread_id: threadId, turn_id: turnId } = args;
|
|
2138
2156
|
const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2139
2157
|
const effectiveNetId = getNetworkId(netId);
|
|
2140
2158
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
2141
2159
|
if (peerCapabilityRequired && !in_reply_to) {
|
|
2142
2160
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
|
|
2143
2161
|
}
|
|
2162
|
+
if ([threadId, turnId].some((v) => v !== undefined && (typeof v !== "string" || v.length < 1 || v.length > 200))) {
|
|
2163
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_invalid", message: "thread_id and turn_id must be 1-200 characters" }) }] };
|
|
2164
|
+
}
|
|
2165
|
+
if ((threadId && !turnId) || (!threadId && turnId)) {
|
|
2166
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_incomplete", message: "thread_id and turn_id must be supplied together" }) }] };
|
|
2167
|
+
}
|
|
2144
2168
|
|
|
2145
2169
|
// #507 — validate attachments BEFORE any DB write. Rejects malformed
|
|
2146
2170
|
// input (bad file_id, >20 items, size > cap, non-object item, wrong
|
|
@@ -2203,6 +2227,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2203
2227
|
const lc = assertNodeActive(replyDeliveryAlias, effectiveNetId ?? null);
|
|
2204
2228
|
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2205
2229
|
}
|
|
2230
|
+
|
|
2231
|
+
// #710 — A recovered runtime turn may finish after its task became
|
|
2232
|
+
// terminal. Preserve that result as an append-only receipt: never
|
|
2233
|
+
// rewrite the task row and never run the parent-task reply chain.
|
|
2234
|
+
if (in_reply_to) {
|
|
2235
|
+
type LateTask = { status: string; from_name: string; to_node_id: string | null; thread_id: string | null; turn_id: string | null };
|
|
2236
|
+
const lateParams: any[] = [in_reply_to];
|
|
2237
|
+
let lateSql = "SELECT status,from_name,to_node_id,thread_id,turn_id FROM tasks WHERE task_id=?1";
|
|
2238
|
+
lateSql = addScope(lateSql, lateParams, effectiveNetId);
|
|
2239
|
+
const lateCandidate = db.get<LateTask>(lateSql, ...lateParams) ?? null;
|
|
2240
|
+
if (lateCandidate && !["created", "delivered", "acked", "running"].includes(lateCandidate.status)) {
|
|
2241
|
+
// Old runtimes do not send turn identity and retain the old result.
|
|
2242
|
+
if (!threadId || !turnId) {
|
|
2243
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2244
|
+
ok: false, error: "reply_task_terminal",
|
|
2245
|
+
message: `cannot apply reply: task is already terminal (${lateCandidate.status})`,
|
|
2246
|
+
in_reply_to, task_status: lateCandidate.status, reply_queued: false,
|
|
2247
|
+
}) }] };
|
|
2248
|
+
}
|
|
2249
|
+
if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
|
|
2250
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_node_token_required", reply_queued: false }) }] };
|
|
2251
|
+
}
|
|
2252
|
+
const token = db.get<{ bound_node_id: string | null }>(
|
|
2253
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id=?1 AND network_id=?2",
|
|
2254
|
+
callerTokenId, enforceNetworkId,
|
|
2255
|
+
);
|
|
2256
|
+
if (!token?.bound_node_id || !lateCandidate.to_node_id || token.bound_node_id !== lateCandidate.to_node_id) {
|
|
2257
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_task_not_owned", reply_queued: false }) }] };
|
|
2258
|
+
}
|
|
2259
|
+
if (!lateReplyContextMatches(lateCandidate.thread_id, lateCandidate.turn_id, threadId, turnId)) {
|
|
2260
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_mismatch", reply_queued: false }) }] };
|
|
2261
|
+
}
|
|
2262
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, lateCandidate.from_name).alias;
|
|
2263
|
+
if (replyTargetAlias !== canonicalOrigin) {
|
|
2264
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_mismatch", reply_queued: false }) }] };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
const lateOutcome = db.transaction(() => {
|
|
2268
|
+
const currentParams: any[] = [in_reply_to];
|
|
2269
|
+
let currentSql = "SELECT status,to_node_id,thread_id,turn_id FROM tasks WHERE task_id=?1";
|
|
2270
|
+
currentSql = addScope(currentSql, currentParams, effectiveNetId);
|
|
2271
|
+
const current = db.get<{ status: string; to_node_id: string | null; thread_id: string | null; turn_id: string | null }>(currentSql, ...currentParams);
|
|
2272
|
+
if (!current || ["created", "delivered", "acked", "running"].includes(current.status)) {
|
|
2273
|
+
return { ok: false as const, error: "late_reply_task_not_terminal" as const };
|
|
2274
|
+
}
|
|
2275
|
+
if (current.to_node_id !== token.bound_node_id || !lateReplyContextMatches(current.thread_id, current.turn_id, threadId, turnId)) {
|
|
2276
|
+
return { ok: false as const, error: "late_reply_context_mismatch" as const };
|
|
2277
|
+
}
|
|
2278
|
+
const existing = db.get<{ late_reply_id: string; inbox_id: string; from_node_id: string; status: string; result: string }>(
|
|
2279
|
+
"SELECT late_reply_id,inbox_id,from_node_id,status,result FROM task_late_replies WHERE task_id=?1 AND thread_id=?2 AND turn_id=?3",
|
|
2280
|
+
in_reply_to, threadId, turnId,
|
|
2281
|
+
);
|
|
2282
|
+
if (existing) {
|
|
2283
|
+
if (existing.from_node_id !== token.bound_node_id || existing.status !== replyStatus || existing.result !== text) {
|
|
2284
|
+
return { ok: false as const, error: "late_reply_conflict" as const };
|
|
2285
|
+
}
|
|
2286
|
+
return { ok: true as const, duplicate: true as const, lateReplyId: existing.late_reply_id, inboxId: existing.inbox_id };
|
|
2287
|
+
}
|
|
2288
|
+
const lateReplyId = uuidv4();
|
|
2289
|
+
const lateMeta = normalizeMetaJson({
|
|
2290
|
+
...(mergedMeta && typeof mergedMeta === "object" && !Array.isArray(mergedMeta) ? mergedMeta : {}),
|
|
2291
|
+
late: true, late_reply_id: lateReplyId, thread_id: threadId, turn_id: turnId,
|
|
2292
|
+
});
|
|
2293
|
+
db.run(
|
|
2294
|
+
`INSERT INTO inbox (id,session_name,node_id,type,priority,content,from_session,in_reply_to,requires_response,network_id,meta_json)
|
|
2295
|
+
VALUES (?1,?2,?3,'reply','normal',?4,?5,?6,'none',?7,?8)`,
|
|
2296
|
+
[id, replyDeliveryAlias, replyTargetNodeId, text, from_session, in_reply_to,
|
|
2297
|
+
effectiveNetId ?? null, lateMeta],
|
|
2298
|
+
);
|
|
2299
|
+
db.run(
|
|
2300
|
+
`INSERT INTO task_late_replies
|
|
2301
|
+
(late_reply_id,inbox_id,task_id,network_id,from_node_id,thread_id,turn_id,status,result,meta_json)
|
|
2302
|
+
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`,
|
|
2303
|
+
[lateReplyId, id, in_reply_to, effectiveNetId ?? null, token.bound_node_id,
|
|
2304
|
+
threadId, turnId, replyStatus, text, lateMeta],
|
|
2305
|
+
);
|
|
2306
|
+
return { ok: true as const, duplicate: false as const, lateReplyId, inboxId: id };
|
|
2307
|
+
});
|
|
2308
|
+
if (!lateOutcome.ok) {
|
|
2309
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: lateOutcome.error, reply_queued: false }) }] };
|
|
2310
|
+
}
|
|
2311
|
+
if (!lateOutcome.duplicate) {
|
|
2312
|
+
pushEvent(replyDeliveryAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyDeliveryAlias, effectiveNetId), from: from_session, message_id: lateOutcome.inboxId, in_reply_to, status: replyStatus, late: true }, effectiveNetId);
|
|
2313
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to, message_id: lateOutcome.inboxId, from: from_session, to: replyDeliveryAlias, status: replyStatus, late: true });
|
|
2314
|
+
}
|
|
2315
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2316
|
+
ok: true, message_id: lateOutcome.inboxId, late_reply_id: lateOutcome.lateReplyId,
|
|
2317
|
+
late: true, duplicate: lateOutcome.duplicate,
|
|
2318
|
+
}) }] };
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2206
2321
|
const replyOutcome = db.transaction(() => {
|
|
2207
2322
|
type ReplyTask = {
|
|
2208
2323
|
status: string;
|
|
@@ -2581,6 +2696,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2581
2696
|
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
2582
2697
|
sql = addAgentTimelineScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2583
2698
|
const task = db.get<any>(sql, ...params);
|
|
2699
|
+
if (task) {
|
|
2700
|
+
task.late_replies = listTaskLateReplies(db, task_id, typeof task.network_id === "string" ? task.network_id : null);
|
|
2701
|
+
}
|
|
2584
2702
|
return {
|
|
2585
2703
|
content: [{
|
|
2586
2704
|
type: "text" as const,
|
|
@@ -6248,6 +6366,41 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6248
6366
|
async ({ id, text, network_id }) => requirementsCall("POST", `/api/requirements/${encodeURIComponent(id)}/comments`, network_id, { text }),
|
|
6249
6367
|
);
|
|
6250
6368
|
|
|
6369
|
+
// Board #733 — Agents manage Hub scheduled tasks. Node tokens only (REST stays people-only); the
|
|
6370
|
+
// permission model lives in schedule-agent.ts.
|
|
6371
|
+
const scheduleCall = (fn: (id: NodeIdentity, canWriteNet: boolean) => Record<string, unknown>) => {
|
|
6372
|
+
let body: Record<string, unknown>;
|
|
6373
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) body = { ok: false, error: "network_token_required" };
|
|
6374
|
+
else {
|
|
6375
|
+
const id = nodeIdentity(callerTokenId, enforceNetworkId);
|
|
6376
|
+
body = id ? fn(id, canWrite(enforceNetworkId)) : { ok: false, error: "node_identity_unbound" };
|
|
6377
|
+
}
|
|
6378
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(body) }] };
|
|
6379
|
+
};
|
|
6380
|
+
// Kept terse on purpose (#476 per-role tools/list ceilings): every value is validated server-side by the
|
|
6381
|
+
// same parseScheduleSpec / validateScheduleCreate as REST, so the schema only names the fields.
|
|
6382
|
+
const schedId = z.string();
|
|
6383
|
+
const schedFields = {
|
|
6384
|
+
name: z.string(), task: z.string(), target_node_id: z.string().optional(),
|
|
6385
|
+
schedule: z.any().describe("once{run_at}|interval{every_seconds>=60}|daily{time}|weekly{time,weekdays}"),
|
|
6386
|
+
timezone: z.string().optional(), priority: z.string().optional(),
|
|
6387
|
+
};
|
|
6388
|
+
server.tool("schedule_list", "Hub schedules for or by you.", { status: z.string().optional() },
|
|
6389
|
+
async ({ status }) => scheduleCall(id => agentListSchedules(id, status)));
|
|
6390
|
+
server.tool("schedule_get", "One schedule.", { schedule_id: schedId },
|
|
6391
|
+
async ({ schedule_id }) => scheduleCall(id => agentGetSchedule(id, schedule_id)));
|
|
6392
|
+
server.tool("schedule_create", "Schedule a task to target_node_id (default you).", schedFields,
|
|
6393
|
+
async (args) => scheduleCall((id, w) => agentCreateSchedule(id, w, args as Record<string, unknown>)));
|
|
6394
|
+
server.tool("schedule_update", "Edit your schedule; status=paused|active.",
|
|
6395
|
+
{ ...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() },
|
|
6396
|
+
async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
|
|
6397
|
+
server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
|
|
6398
|
+
async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
|
|
6399
|
+
server.tool("schedule_run_now", "Run your schedule now.", { schedule_id: schedId },
|
|
6400
|
+
async ({ schedule_id }) => scheduleCall((id, w) => agentRunScheduleNow(id, w, schedule_id)));
|
|
6401
|
+
server.tool("schedule_runs", "A schedule's recent runs.", { schedule_id: schedId },
|
|
6402
|
+
async ({ schedule_id }) => scheduleCall(id => agentScheduleRuns(id, schedule_id)));
|
|
6403
|
+
|
|
6251
6404
|
// #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
|
|
6252
6405
|
const caller: ToolCaller = callerTokenIsNetwork
|
|
6253
6406
|
? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }
|