@sleep2agi/commhub-server 0.9.0-preview.65 → 0.9.0-preview.67
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/db.ts +8 -1
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.ts +17 -1
- package/src/requirements-http.test.ts +399 -10
- package/src/requirements-mcp.test.ts +132 -0
- package/src/requirements-migrate.ts +51 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +607 -95
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +90 -0
- package/src/scheduled-tasks.ts +45 -10
- package/src/server.ts +1 -0
- package/src/tools.ts +183 -11
package/src/tools.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
|
|
|
8
8
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
9
9
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
10
10
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
-
import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
|
|
11
|
+
import { canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId } from "./network-scope.js";
|
|
12
|
+
import { handleRequirementsRequest } from "./requirements.js";
|
|
12
13
|
import {
|
|
13
14
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
14
15
|
validateName as validateChildName,
|
|
@@ -64,7 +65,7 @@ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
|
64
65
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
65
66
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
66
67
|
import { parseHubTimestamp } from "./hub-timestamp";
|
|
67
|
-
import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
68
|
+
import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
68
69
|
|
|
69
70
|
function ts(): string {
|
|
70
71
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -730,8 +731,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
730
731
|
skills_capable: z.literal(true).optional(),
|
|
731
732
|
// Project folder view — same doorbell, ops files_list / file_read.
|
|
732
733
|
files_capable: z.literal(true).optional(),
|
|
734
|
+
// Node run-log view — same doorbell, op logs_tail.
|
|
735
|
+
logs_capable: z.literal(true).optional(),
|
|
733
736
|
},
|
|
734
|
-
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable }) => {
|
|
737
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable }) => {
|
|
735
738
|
const effectiveNetId = getNetworkId(netId);
|
|
736
739
|
const sessionNetId = effectiveNetId ?? "default";
|
|
737
740
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -943,6 +946,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
943
946
|
if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
944
947
|
db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
945
948
|
}
|
|
949
|
+
if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
950
|
+
db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
951
|
+
}
|
|
946
952
|
if (host || proc) {
|
|
947
953
|
db.run(
|
|
948
954
|
`INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
|
|
@@ -3201,11 +3207,43 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3201
3207
|
return segs.join("/");
|
|
3202
3208
|
};
|
|
3203
3209
|
|
|
3210
|
+
// Node run-log view — logs_tail rides the same queue + doorbell. `content`
|
|
3211
|
+
// carries the filter parameters as JSON (lines / level / grep / since_ts) and
|
|
3212
|
+
// never a path: the node reads only its own log directory and redacts before
|
|
3213
|
+
// it answers (agent-node/src/runtime/node-logs.ts). Stricter than the project
|
|
3214
|
+
// folder on the caller: user login only, and only the node's owner or a
|
|
3215
|
+
// network owner/admin — a member who may chat with a node does not get to read
|
|
3216
|
+
// its process log. Log bytes are purged on the first terminal read (see
|
|
3217
|
+
// get_rules_file_result) and by the retention sweep after LOGS_CONTENT_TTL_MS.
|
|
3218
|
+
const isLogsOp = (op: string) => op === "logs_tail";
|
|
3219
|
+
const LOGS_MAX_LINES = 2000;
|
|
3220
|
+
const LOGS_GREP_MAX = 200;
|
|
3221
|
+
const logsParamsJson = (p: { lines?: number; level?: string; grep?: string; since_ts?: number }): string =>
|
|
3222
|
+
JSON.stringify({
|
|
3223
|
+
lines: Math.min(Math.max(Math.floor(p.lines ?? 500), 1), LOGS_MAX_LINES),
|
|
3224
|
+
...(p.level ? { level: p.level } : {}),
|
|
3225
|
+
...(p.grep ? { grep: p.grep.slice(0, LOGS_GREP_MAX) } : {}),
|
|
3226
|
+
...(typeof p.since_ts === "number" && p.since_ts > 0 ? { since_ts: p.since_ts } : {}),
|
|
3227
|
+
});
|
|
3228
|
+
// Owner of the node row, or owner/admin of the network. Legacy open mode (no
|
|
3229
|
+
// user identity on the connection) keeps canWrite's allow-all semantics.
|
|
3230
|
+
const canReadNodeLogs = (nodeId: string, networkId: string): boolean => {
|
|
3231
|
+
if (!enforceUserId) return true;
|
|
3232
|
+
const role = getUserNetworkRole(enforceUserId, networkId);
|
|
3233
|
+
if (role === "owner" || role === "admin") return true;
|
|
3234
|
+
if (nodeId.startsWith("session:")) return false;
|
|
3235
|
+
const row = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", nodeId);
|
|
3236
|
+
return !!row?.owner_user_id && row.owner_user_id === enforceUserId;
|
|
3237
|
+
};
|
|
3238
|
+
|
|
3204
3239
|
const enqueueRulesFileRequest = (
|
|
3205
|
-
op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
|
|
3240
|
+
op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read" | "logs_tail",
|
|
3206
3241
|
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3207
3242
|
) => {
|
|
3208
3243
|
const effectiveNetId = getNetworkId(a.network_id);
|
|
3244
|
+
if (isLogsOp(op) && callerTokenIsNetwork) {
|
|
3245
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_read_logs", message: "a node token cannot read another node's run log; use a user login" }) }] };
|
|
3246
|
+
}
|
|
3209
3247
|
if (isFilesOp(op)) {
|
|
3210
3248
|
if (callerTokenIsNetwork) {
|
|
3211
3249
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
|
|
@@ -3217,7 +3255,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3217
3255
|
}
|
|
3218
3256
|
a = { ...a, content: rel };
|
|
3219
3257
|
}
|
|
3220
|
-
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3258
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isLogsOp(op) ? "read node logs" : isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3221
3259
|
if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
|
|
3222
3260
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
|
|
3223
3261
|
}
|
|
@@ -3247,7 +3285,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3247
3285
|
node = byAlias;
|
|
3248
3286
|
} else {
|
|
3249
3287
|
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3250
|
-
|
|
3288
|
+
isLogsOp(op)
|
|
3289
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND logs_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3290
|
+
: isFilesOp(op)
|
|
3251
3291
|
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3252
3292
|
: isSkillsOp(op)
|
|
3253
3293
|
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
@@ -3256,6 +3296,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3256
3296
|
scopeNet,
|
|
3257
3297
|
);
|
|
3258
3298
|
if (!session) {
|
|
3299
|
+
if (isLogsOp(op)) {
|
|
3300
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its run log (its agent-node may be too old)" }) }] };
|
|
3301
|
+
}
|
|
3259
3302
|
if (isFilesOp(op)) {
|
|
3260
3303
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
|
|
3261
3304
|
}
|
|
@@ -3267,6 +3310,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3267
3310
|
}
|
|
3268
3311
|
}
|
|
3269
3312
|
const nodeId = node.node_id;
|
|
3313
|
+
if (isLogsOp(op) && !canReadNodeLogs(nodeId, node.network_id || "default")) {
|
|
3314
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_permission_denied", message: "only the node's owner or a network owner/admin can read its run log" }) }] };
|
|
3315
|
+
}
|
|
3270
3316
|
if (op === "write") {
|
|
3271
3317
|
if (typeof a.content !== "string") {
|
|
3272
3318
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
|
|
@@ -3282,7 +3328,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3282
3328
|
// Separate single-flight lanes: the client opens the rules file and the
|
|
3283
3329
|
// skills list together; one must not block the other.
|
|
3284
3330
|
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3285
|
-
|
|
3331
|
+
isLogsOp(op)
|
|
3332
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op = 'logs_tail' ORDER BY created_at DESC LIMIT 1"
|
|
3333
|
+
: isFilesOp(op)
|
|
3286
3334
|
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
|
|
3287
3335
|
: isSkillsOp(op)
|
|
3288
3336
|
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
|
|
@@ -3306,7 +3354,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3306
3354
|
const networkId = node.network_id || "default";
|
|
3307
3355
|
db.run(
|
|
3308
3356
|
`INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3309
|
-
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3357
|
+
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) || isLogsOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3310
3358
|
);
|
|
3311
3359
|
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3312
3360
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
@@ -3382,6 +3430,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3382
3430
|
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
|
|
3383
3431
|
);
|
|
3384
3432
|
|
|
3433
|
+
server.tool(
|
|
3434
|
+
"tail_node_logs",
|
|
3435
|
+
"Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
|
|
3436
|
+
{
|
|
3437
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3438
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported logs_capable."),
|
|
3439
|
+
lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
|
|
3440
|
+
level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
|
|
3441
|
+
grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
|
|
3442
|
+
since_ts: z.number().int().min(0).optional().describe("Only lines at or after this epoch-ms timestamp (follow mode; the client de-duplicates by line key)."),
|
|
3443
|
+
network_id: z.string().max(200).optional(),
|
|
3444
|
+
},
|
|
3445
|
+
async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
|
|
3446
|
+
enqueueRulesFileRequest("logs_tail", { node_id, child_node_id, alias, content: logsParamsJson({ lines, level, grep, since_ts }), network_id }),
|
|
3447
|
+
);
|
|
3448
|
+
|
|
3385
3449
|
server.tool(
|
|
3386
3450
|
"get_rules_file_request",
|
|
3387
3451
|
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
@@ -3419,7 +3483,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3419
3483
|
request: {
|
|
3420
3484
|
request_id: req.request_id,
|
|
3421
3485
|
op: req.op,
|
|
3422
|
-
...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
|
|
3486
|
+
...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) || isLogsOp(req.op) ? { content: req.content ?? "" } : {}),
|
|
3423
3487
|
},
|
|
3424
3488
|
}),
|
|
3425
3489
|
}],
|
|
@@ -3460,7 +3524,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3460
3524
|
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
3461
3525
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
|
|
3462
3526
|
}
|
|
3463
|
-
if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
|
|
3527
|
+
if (typeof content === "string" && !isFilesOp(req.op) && !isLogsOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
|
|
3464
3528
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
|
|
3465
3529
|
}
|
|
3466
3530
|
db.run(
|
|
@@ -3492,7 +3556,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3492
3556
|
);
|
|
3493
3557
|
// SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
|
|
3494
3558
|
// Project folder results: only the (user) token that asked may read them.
|
|
3495
|
-
|
|
3559
|
+
// Run-log results: same rule — only the token that asked.
|
|
3560
|
+
const foreignFilesRow = !!row && (isFilesOp(row.op) || isLogsOp(row.op)) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
|
|
3496
3561
|
if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
|
|
3497
3562
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
|
|
3498
3563
|
}
|
|
@@ -3511,6 +3576,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3511
3576
|
// after the SEC-1 scope check above — a foreign caller can't stamp/purge.
|
|
3512
3577
|
const terminal = status === "done" || status === "failed" || status === "timeout";
|
|
3513
3578
|
const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
|
|
3579
|
+
// Run-log bytes are read-once: purge them in the same call that hands them
|
|
3580
|
+
// out (no grace window — the app never follows someone else's logs request,
|
|
3581
|
+
// it re-asks; see node-request-retention.ts).
|
|
3582
|
+
if (terminal && isLogsOp(row.op) && !purged) purgeLogsResultNow(requestId);
|
|
3514
3583
|
return {
|
|
3515
3584
|
content: [{
|
|
3516
3585
|
type: "text" as const,
|
|
@@ -5606,6 +5675,109 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5606
5675
|
}
|
|
5607
5676
|
},
|
|
5608
5677
|
);
|
|
5678
|
+
|
|
5679
|
+
// ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
|
|
5680
|
+
// 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
|
|
5681
|
+
// 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
|
|
5682
|
+
const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
|
|
5683
|
+
const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
|
|
5684
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
|
|
5685
|
+
const url = new URL(`http://mcp.internal${path}`);
|
|
5686
|
+
const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
|
|
5687
|
+
const res = await handleRequirementsRequest({
|
|
5688
|
+
req, url,
|
|
5689
|
+
auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
|
|
5690
|
+
isAdmin: false,
|
|
5691
|
+
isNodeToken: callerTokenIsNetwork,
|
|
5692
|
+
scope,
|
|
5693
|
+
});
|
|
5694
|
+
const data = res ? await res.json() : { ok: false, error: "not_found" };
|
|
5695
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
|
|
5696
|
+
};
|
|
5697
|
+
const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
|
|
5698
|
+
const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
|
|
5699
|
+
const reqFields = {
|
|
5700
|
+
name: z.string().min(1).max(80).optional(),
|
|
5701
|
+
priority: z.enum(["high", "normal", "low"]).optional(),
|
|
5702
|
+
column: z.enum(["pool", "doing", "done"]).optional(),
|
|
5703
|
+
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
|
|
5704
|
+
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5705
|
+
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5706
|
+
project_id: z.string().max(200).nullable().optional(),
|
|
5707
|
+
owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
|
|
5708
|
+
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
|
|
5709
|
+
participants: z.array(reqPerson).max(100).optional(),
|
|
5710
|
+
external_url: z.string().max(500).nullable().optional(),
|
|
5711
|
+
parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
|
|
5712
|
+
};
|
|
5713
|
+
const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
5714
|
+
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5715
|
+
|
|
5716
|
+
server.tool(
|
|
5717
|
+
"requirements_list",
|
|
5718
|
+
"List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
|
|
5719
|
+
{
|
|
5720
|
+
network_id: z.string().max(200).optional(),
|
|
5721
|
+
status: z.enum(["pool", "doing", "done"]).optional(),
|
|
5722
|
+
project_id: z.string().max(200).optional(),
|
|
5723
|
+
owner: z.string().max(210).optional(),
|
|
5724
|
+
agent_owner: z.string().max(210).optional(),
|
|
5725
|
+
updated_since: z.string().max(40).optional(),
|
|
5726
|
+
external_ref: z.string().max(200).optional(),
|
|
5727
|
+
include_archived: z.boolean().optional(),
|
|
5728
|
+
parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
|
|
5729
|
+
top_level: z.boolean().optional(),
|
|
5730
|
+
},
|
|
5731
|
+
async (args) => {
|
|
5732
|
+
const q = new URLSearchParams();
|
|
5733
|
+
for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5734
|
+
if (args.include_archived) q.set("include_archived", "1");
|
|
5735
|
+
if (args.top_level) q.set("top_level", "1");
|
|
5736
|
+
return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
|
|
5737
|
+
},
|
|
5738
|
+
);
|
|
5739
|
+
|
|
5740
|
+
server.tool(
|
|
5741
|
+
"requirements_get",
|
|
5742
|
+
"Get one requirement task by id (with description, checklist, owners, project, external_ref).",
|
|
5743
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
5744
|
+
async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
|
|
5745
|
+
);
|
|
5746
|
+
|
|
5747
|
+
server.tool(
|
|
5748
|
+
"requirements_create",
|
|
5749
|
+
"Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
5750
|
+
{ network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
5751
|
+
async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5752
|
+
);
|
|
5753
|
+
|
|
5754
|
+
server.tool(
|
|
5755
|
+
"requirements_update",
|
|
5756
|
+
"Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5757
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5758
|
+
async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5759
|
+
);
|
|
5760
|
+
|
|
5761
|
+
server.tool(
|
|
5762
|
+
"requirements_checklist_toggle",
|
|
5763
|
+
"Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
|
|
5764
|
+
{ id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
|
|
5765
|
+
async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
|
|
5766
|
+
);
|
|
5767
|
+
|
|
5768
|
+
server.tool(
|
|
5769
|
+
"requirements_upsert_by_external_ref",
|
|
5770
|
+
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including status — are kept). Returns { requirement, created }.",
|
|
5771
|
+
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
|
|
5772
|
+
async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5773
|
+
);
|
|
5774
|
+
|
|
5775
|
+
server.tool(
|
|
5776
|
+
"projects_list",
|
|
5777
|
+
"List requirement projects in your network (id, name, color, sort, archived).",
|
|
5778
|
+
{ network_id: z.string().max(200).optional() },
|
|
5779
|
+
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
5780
|
+
);
|
|
5609
5781
|
}
|
|
5610
5782
|
|
|
5611
5783
|
// ────────────────────────────────────────────────────────────────────
|