siclaw 0.1.2 → 0.1.4
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/dist/agentbox/gateway-client.d.ts +4 -0
- package/dist/agentbox/gateway-client.js +9 -1
- package/dist/agentbox/gateway-client.js.map +1 -1
- package/dist/agentbox/http-server.js +25 -1
- package/dist/agentbox/http-server.js.map +1 -1
- package/dist/agentbox/session.d.ts +2 -0
- package/dist/agentbox/session.js +11 -7
- package/dist/agentbox/session.js.map +1 -1
- package/dist/agentbox-main.js +10 -0
- package/dist/agentbox-main.js.map +1 -1
- package/dist/cli-main.js +19 -3
- package/dist/cli-main.js.map +1 -1
- package/dist/core/agent-factory.d.ts +2 -0
- package/dist/core/agent-factory.js +87 -21
- package/dist/core/agent-factory.js.map +1 -1
- package/dist/core/compaction.d.ts +80 -0
- package/dist/core/compaction.js +442 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.js +27 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/extensions/compaction-safeguard.d.ts +2 -0
- package/dist/core/extensions/compaction-safeguard.js +681 -0
- package/dist/core/extensions/compaction-safeguard.js.map +1 -0
- package/dist/core/extensions/deep-investigation.js +47 -73
- package/dist/core/extensions/deep-investigation.js.map +1 -1
- package/dist/core/extensions/memory-flush.d.ts +2 -10
- package/dist/core/extensions/memory-flush.js +4 -86
- package/dist/core/extensions/memory-flush.js.map +1 -1
- package/dist/core/llm-proxy.js +25 -6
- package/dist/core/llm-proxy.js.map +1 -1
- package/dist/core/message-utils.d.ts +18 -0
- package/dist/core/message-utils.js +28 -0
- package/dist/core/message-utils.js.map +1 -0
- package/dist/core/prompt.js +4 -5
- package/dist/core/prompt.js.map +1 -1
- package/dist/core/session-tool-result-guard.d.ts +2 -0
- package/dist/core/session-tool-result-guard.js +159 -0
- package/dist/core/session-tool-result-guard.js.map +1 -0
- package/dist/core/stream-wrappers.d.ts +41 -0
- package/dist/core/stream-wrappers.js +369 -0
- package/dist/core/stream-wrappers.js.map +1 -0
- package/dist/core/thinking-blocks.d.ts +20 -0
- package/dist/core/thinking-blocks.js +45 -0
- package/dist/core/thinking-blocks.js.map +1 -0
- package/dist/core/tool-call-id.d.ts +22 -0
- package/dist/core/tool-call-id.js +226 -0
- package/dist/core/tool-call-id.js.map +1 -0
- package/dist/core/tool-call-repair.d.ts +18 -0
- package/dist/core/tool-call-repair.js +73 -0
- package/dist/core/tool-call-repair.js.map +1 -0
- package/dist/core/tool-result-context-guard.d.ts +36 -0
- package/dist/core/tool-result-context-guard.js +272 -0
- package/dist/core/tool-result-context-guard.js.map +1 -0
- package/dist/cron/cron-limits.d.ts +16 -0
- package/dist/cron/cron-limits.js +17 -0
- package/dist/cron/cron-limits.js.map +1 -0
- package/dist/cron/cron-matcher.d.ts +14 -0
- package/dist/cron/cron-matcher.js +29 -0
- package/dist/cron/cron-matcher.js.map +1 -1
- package/dist/gateway/agentbox/client.d.ts +0 -2
- package/dist/gateway/agentbox/client.js.map +1 -1
- package/dist/gateway/agentbox/k8s-spawner.d.ts +10 -10
- package/dist/gateway/agentbox/k8s-spawner.js +27 -55
- package/dist/gateway/agentbox/k8s-spawner.js.map +1 -1
- package/dist/gateway/agentbox/local-spawner.d.ts +5 -0
- package/dist/gateway/agentbox/local-spawner.js +10 -0
- package/dist/gateway/agentbox/local-spawner.js.map +1 -1
- package/dist/gateway/cron/cron-service.js +7 -0
- package/dist/gateway/cron/cron-service.js.map +1 -1
- package/dist/gateway/db/index.js +9 -1
- package/dist/gateway/db/index.js.map +1 -1
- package/dist/gateway/db/init-schema.js +65 -16
- package/dist/gateway/db/init-schema.js.map +1 -1
- package/dist/gateway/db/migrate-sqlite.js +73 -20
- package/dist/gateway/db/migrate-sqlite.js.map +1 -1
- package/dist/gateway/db/repositories/cluster-repo.d.ts +59 -0
- package/dist/gateway/db/repositories/cluster-repo.js +107 -0
- package/dist/gateway/db/repositories/cluster-repo.js.map +1 -0
- package/dist/gateway/db/repositories/config-repo.d.ts +4 -5
- package/dist/gateway/db/repositories/config-repo.js +17 -0
- package/dist/gateway/db/repositories/config-repo.js.map +1 -1
- package/dist/gateway/db/repositories/feedback-repo.d.ts +71 -0
- package/dist/gateway/db/repositories/feedback-repo.js +52 -0
- package/dist/gateway/db/repositories/feedback-repo.js.map +1 -0
- package/dist/gateway/db/repositories/knowledge-doc-repo.d.ts +37 -0
- package/dist/gateway/db/repositories/knowledge-doc-repo.js +48 -0
- package/dist/gateway/db/repositories/knowledge-doc-repo.js.map +1 -0
- package/dist/gateway/db/repositories/user-cluster-config-repo.d.ts +45 -0
- package/dist/gateway/db/repositories/user-cluster-config-repo.js +90 -0
- package/dist/gateway/db/repositories/user-cluster-config-repo.js.map +1 -0
- package/dist/gateway/db/repositories/workspace-repo.d.ts +2 -2
- package/dist/gateway/db/repositories/workspace-repo.js +12 -12
- package/dist/gateway/db/repositories/workspace-repo.js.map +1 -1
- package/dist/gateway/db/schema-mysql.d.ts +437 -44
- package/dist/gateway/db/schema-mysql.js +36 -9
- package/dist/gateway/db/schema-mysql.js.map +1 -1
- package/dist/gateway/db/schema-sqlite.d.ts +459 -46
- package/dist/gateway/db/schema-sqlite.js +36 -9
- package/dist/gateway/db/schema-sqlite.js.map +1 -1
- package/dist/gateway/db/schema.d.ts +435 -44
- package/dist/gateway/db/schema.js +1 -1
- package/dist/gateway/db/schema.js.map +1 -1
- package/dist/gateway/plugins/channel-bridge.js +1 -1
- package/dist/gateway/plugins/channel-bridge.js.map +1 -1
- package/dist/gateway/rpc-methods.d.ts +2 -1
- package/dist/gateway/rpc-methods.js +507 -172
- package/dist/gateway/rpc-methods.js.map +1 -1
- package/dist/gateway/server.js +191 -51
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/web/dist/assets/index-DTD0P9j8.css +1 -0
- package/dist/gateway/web/dist/assets/index-DhqsS2E0.js +756 -0
- package/dist/gateway/web/dist/assets/index-DhqsS2E0.js.map +1 -0
- package/dist/gateway/web/dist/index.html +2 -2
- package/dist/gateway-main.js +1 -3
- package/dist/gateway-main.js.map +1 -1
- package/dist/memory/indexer.d.ts +13 -0
- package/dist/memory/indexer.js +91 -1
- package/dist/memory/indexer.js.map +1 -1
- package/dist/memory/knowledge-extractor.d.ts +47 -0
- package/dist/memory/knowledge-extractor.js +165 -0
- package/dist/memory/knowledge-extractor.js.map +1 -0
- package/dist/memory/overview-generator.d.ts +16 -0
- package/dist/memory/overview-generator.js +233 -0
- package/dist/memory/overview-generator.js.map +1 -0
- package/dist/memory/session-summarizer.d.ts +28 -0
- package/dist/memory/session-summarizer.js +20 -2
- package/dist/memory/session-summarizer.js.map +1 -1
- package/dist/memory/temporal-decay.js +2 -2
- package/dist/memory/temporal-decay.js.map +1 -1
- package/dist/memory/topic-consolidator.d.ts +52 -0
- package/dist/memory/topic-consolidator.js +197 -0
- package/dist/memory/topic-consolidator.js.map +1 -0
- package/dist/tools/cluster-info.d.ts +9 -0
- package/dist/tools/cluster-info.js +74 -0
- package/dist/tools/cluster-info.js.map +1 -0
- package/dist/tools/command-sets.js +15 -5
- package/dist/tools/command-sets.js.map +1 -1
- package/dist/tools/create-skill.js +1 -1
- package/dist/tools/create-skill.js.map +1 -1
- package/dist/tools/debug-pod.d.ts +217 -0
- package/dist/tools/debug-pod.js +603 -0
- package/dist/tools/debug-pod.js.map +1 -0
- package/dist/tools/deep-search/engine.d.ts +0 -5
- package/dist/tools/deep-search/engine.js +68 -28
- package/dist/tools/deep-search/engine.js.map +1 -1
- package/dist/tools/deep-search/format.d.ts +1 -1
- package/dist/tools/deep-search/format.js +1 -2
- package/dist/tools/deep-search/format.js.map +1 -1
- package/dist/tools/deep-search/prompts.d.ts +4 -1
- package/dist/tools/deep-search/prompts.js +47 -29
- package/dist/tools/deep-search/prompts.js.map +1 -1
- package/dist/tools/deep-search/quality-gate.d.ts +25 -0
- package/dist/tools/deep-search/quality-gate.js +81 -0
- package/dist/tools/deep-search/quality-gate.js.map +1 -0
- package/dist/tools/deep-search/schemas.d.ts +25 -0
- package/dist/tools/deep-search/schemas.js +26 -1
- package/dist/tools/deep-search/schemas.js.map +1 -1
- package/dist/tools/deep-search/sre-knowledge.d.ts +6 -10
- package/dist/tools/deep-search/sre-knowledge.js +21 -52
- package/dist/tools/deep-search/sre-knowledge.js.map +1 -1
- package/dist/tools/deep-search/sub-agent.js +24 -8
- package/dist/tools/deep-search/sub-agent.js.map +1 -1
- package/dist/tools/deep-search/tool.js +3 -6
- package/dist/tools/deep-search/tool.js.map +1 -1
- package/dist/tools/deep-search/types.d.ts +13 -0
- package/dist/tools/deep-search/types.js +4 -4
- package/dist/tools/deep-search/types.js.map +1 -1
- package/dist/tools/dp-tools.d.ts +9 -6
- package/dist/tools/dp-tools.js +26 -55
- package/dist/tools/dp-tools.js.map +1 -1
- package/dist/tools/exec-utils.d.ts +8 -21
- package/dist/tools/exec-utils.js +11 -95
- package/dist/tools/exec-utils.js.map +1 -1
- package/dist/tools/fork-skill.js +1 -1
- package/dist/tools/fork-skill.js.map +1 -1
- package/dist/tools/k8s-checks.d.ts +11 -5
- package/dist/tools/k8s-checks.js +28 -9
- package/dist/tools/k8s-checks.js.map +1 -1
- package/dist/tools/knowledge-search.d.ts +3 -0
- package/dist/tools/knowledge-search.js +115 -0
- package/dist/tools/knowledge-search.js.map +1 -0
- package/dist/tools/kubeconfig-resolver.d.ts +22 -0
- package/dist/tools/kubeconfig-resolver.js +98 -18
- package/dist/tools/kubeconfig-resolver.js.map +1 -1
- package/dist/tools/manage-schedule.js +23 -1
- package/dist/tools/manage-schedule.js.map +1 -1
- package/dist/tools/netns-script.d.ts +1 -1
- package/dist/tools/netns-script.js +19 -7
- package/dist/tools/netns-script.js.map +1 -1
- package/dist/tools/node-exec.d.ts +1 -1
- package/dist/tools/node-exec.js +19 -7
- package/dist/tools/node-exec.js.map +1 -1
- package/dist/tools/node-script.d.ts +1 -1
- package/dist/tools/node-script.js +19 -7
- package/dist/tools/node-script.js.map +1 -1
- package/dist/tools/pod-exec.js +12 -1
- package/dist/tools/pod-exec.js.map +1 -1
- package/dist/tools/pod-nsenter-exec.d.ts +1 -1
- package/dist/tools/pod-nsenter-exec.js +19 -7
- package/dist/tools/pod-nsenter-exec.js.map +1 -1
- package/dist/tools/pod-script.js +12 -1
- package/dist/tools/pod-script.js.map +1 -1
- package/dist/tools/restricted-bash.js +10 -3
- package/dist/tools/restricted-bash.js.map +1 -1
- package/dist/tools/run-skill.js +14 -2
- package/dist/tools/run-skill.js.map +1 -1
- package/dist/tools/save-feedback.d.ts +7 -0
- package/dist/tools/save-feedback.js +125 -0
- package/dist/tools/save-feedback.js.map +1 -0
- package/dist/tools/update-skill.js +1 -1
- package/dist/tools/update-skill.js.map +1 -1
- package/package.json +1 -1
- package/skills/core/deep-investigation/SKILL.md +11 -14
- package/skills/core/session-feedback/SKILL.md +146 -0
|
@@ -21,8 +21,8 @@ import { ModelConfigRepository } from "./db/repositories/model-config-repo.js";
|
|
|
21
21
|
import { CredentialRepository } from "./db/repositories/credential-repo.js";
|
|
22
22
|
import { WorkspaceRepository } from "./db/repositories/workspace-repo.js";
|
|
23
23
|
import { SystemConfigRepository } from "./db/repositories/system-config-repo.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { ClusterRepository } from "./db/repositories/cluster-repo.js";
|
|
25
|
+
import { UserClusterConfigRepository } from "./db/repositories/user-cluster-config-repo.js";
|
|
26
26
|
import { getLabelsForSkill, batchGetLabels, listAllLabels } from "./skill-labels.js";
|
|
27
27
|
import { McpServerRepository } from "./db/repositories/mcp-server-repo.js";
|
|
28
28
|
import { SkillFileWriter } from "./skills/file-writer.js";
|
|
@@ -31,11 +31,16 @@ import { ScriptEvaluator } from "./skills/script-evaluator.js";
|
|
|
31
31
|
import { SkillVersionRepository } from "./db/repositories/skill-version-repo.js";
|
|
32
32
|
import { createTwoFilesPatch } from "diff";
|
|
33
33
|
import yaml from "js-yaml";
|
|
34
|
+
import { CRON_LIMITS } from "../cron/cron-limits.js";
|
|
35
|
+
import { parseCronExpression, getAverageIntervalMs } from "../cron/cron-matcher.js";
|
|
34
36
|
import { buildSkillBundle } from "./skills/skill-bundle.js";
|
|
35
37
|
import { buildRedactionConfig, redactText } from "./output-redactor.js";
|
|
36
38
|
import { RESOURCE_DESCRIPTORS } from "../shared/resource-sync.js";
|
|
37
39
|
import { sql, gte, sum, count } from "drizzle-orm";
|
|
38
40
|
import { sessionStats } from "./db/schema.js";
|
|
41
|
+
import { KnowledgeDocRepository } from "./db/repositories/knowledge-doc-repo.js";
|
|
42
|
+
import { resolveUnderDir } from "../shared/path-utils.js";
|
|
43
|
+
import { loadConfig } from "../core/config.js";
|
|
39
44
|
function requireAuth(context) {
|
|
40
45
|
const userId = context.auth?.userId;
|
|
41
46
|
if (!userId)
|
|
@@ -69,7 +74,58 @@ function apiServerHostMatch(kubeconfigServer, envApiServer) {
|
|
|
69
74
|
return false;
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
|
-
|
|
77
|
+
// ── Feedback enrichment helpers ──────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Read the session-feedback SKILL.md content, stripping YAML frontmatter.
|
|
80
|
+
* Uses import.meta.url to resolve the package root (works in both dev and K8s).
|
|
81
|
+
*/
|
|
82
|
+
const _feedbackModDir = path.dirname(fileURLToPath(import.meta.url));
|
|
83
|
+
const _feedbackPkgRoot = path.resolve(_feedbackModDir, "..", "..");
|
|
84
|
+
let _feedbackSkillCache = null;
|
|
85
|
+
async function readFeedbackSkillContent() {
|
|
86
|
+
if (_feedbackSkillCache)
|
|
87
|
+
return _feedbackSkillCache;
|
|
88
|
+
const skillPath = path.join(_feedbackPkgRoot, "skills", "core", "session-feedback", "SKILL.md");
|
|
89
|
+
const raw = await fs.promises.readFile(skillPath, "utf-8");
|
|
90
|
+
// Strip YAML frontmatter (between --- delimiters)
|
|
91
|
+
const stripped = raw.replace(/^---[\s\S]*?---\s*/, "").trim();
|
|
92
|
+
_feedbackSkillCache = stripped;
|
|
93
|
+
return stripped;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build a markdown timeline of the session's diagnostic activity.
|
|
97
|
+
*/
|
|
98
|
+
async function buildSessionTimeline(chatRepo, sessionId) {
|
|
99
|
+
const msgs = await chatRepo.getMessages(sessionId, { limit: 200 });
|
|
100
|
+
if (msgs.length === 0)
|
|
101
|
+
return "_No messages in this session._";
|
|
102
|
+
const lines = [];
|
|
103
|
+
let stepNum = 0;
|
|
104
|
+
for (const msg of msgs) {
|
|
105
|
+
if (msg.role === "user") {
|
|
106
|
+
// Skip feedback sentinel messages from timeline
|
|
107
|
+
if (msg.content.startsWith("[Feedback]"))
|
|
108
|
+
continue;
|
|
109
|
+
stepNum++;
|
|
110
|
+
const preview = msg.content.length > 100 ? msg.content.slice(0, 100) + "..." : msg.content;
|
|
111
|
+
lines.push(`${stepNum}. **User**: ${preview}`);
|
|
112
|
+
}
|
|
113
|
+
else if (msg.role === "tool" && msg.toolName) {
|
|
114
|
+
stepNum++;
|
|
115
|
+
const outcome = msg.outcome ?? "unknown";
|
|
116
|
+
const duration = msg.durationMs != null ? ` (${msg.durationMs}ms)` : "";
|
|
117
|
+
lines.push(`${stepNum}. **Tool** \`${msg.toolName}\`: ${outcome}${duration}`);
|
|
118
|
+
}
|
|
119
|
+
else if (msg.role === "assistant") {
|
|
120
|
+
// Summarize assistant messages briefly
|
|
121
|
+
const preview = msg.content.length > 100 ? msg.content.slice(0, 100) + "..." : msg.content;
|
|
122
|
+
stepNum++;
|
|
123
|
+
lines.push(`${stepNum}. **Assistant**: ${preview}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, activePromptUsers, agentBoxTlsOptions, resourceNotifier, metricsAggregator, cronService, knowledgeIndexer) {
|
|
73
129
|
const methods = new Map();
|
|
74
130
|
// Initialize repositories (null-safe — methods check before use)
|
|
75
131
|
const chatRepo = db ? new ChatRepository(db) : null;
|
|
@@ -86,9 +142,10 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
86
142
|
const workspaceRepo = db ? new WorkspaceRepository(db) : null;
|
|
87
143
|
const sysConfigRepo = db ? new SystemConfigRepository(db) : null;
|
|
88
144
|
const mcpRepo = db ? new McpServerRepository(db) : null;
|
|
145
|
+
const knowledgeDocRepo = db ? new KnowledgeDocRepository(db) : null;
|
|
89
146
|
const skillContentRepo = db ? new SkillContentRepository(db) : null;
|
|
90
|
-
const
|
|
91
|
-
const
|
|
147
|
+
const clusterRepo = db ? new ClusterRepository(db) : null;
|
|
148
|
+
const userClusterConfigRepo = db ? new UserClusterConfigRepository(db) : null;
|
|
92
149
|
const scriptEvaluator = new ScriptEvaluator(modelConfigRepo);
|
|
93
150
|
/** Resolve workspaceId for a session from DB */
|
|
94
151
|
async function resolveSessionWorkspace(sessionId) {
|
|
@@ -366,12 +423,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
366
423
|
content: message,
|
|
367
424
|
});
|
|
368
425
|
await chatRepo.incrementMessageCount(sessionId);
|
|
369
|
-
// Update session metadata (title/preview)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
426
|
+
// Update session metadata (title/preview) — skip for feedback sentinel
|
|
427
|
+
if (!message.startsWith("[Feedback]")) {
|
|
428
|
+
const title = message.length > 40 ? message.slice(0, 40) + "..." : message;
|
|
429
|
+
await chatRepo.updateSessionMeta(sessionId, {
|
|
430
|
+
title,
|
|
431
|
+
preview: message.slice(0, 100),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
375
434
|
}
|
|
376
435
|
if (!sessionId)
|
|
377
436
|
throw new Error("Failed to create session");
|
|
@@ -417,8 +476,30 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
417
476
|
podEnv,
|
|
418
477
|
});
|
|
419
478
|
const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
|
|
479
|
+
// === Feedback enrichment (system-level) ===
|
|
480
|
+
let promptText = message;
|
|
481
|
+
if (message.startsWith("[Feedback]") && !chatRepo) {
|
|
482
|
+
throw new Error("Feedback requires a database connection (not available in TUI mode).");
|
|
483
|
+
}
|
|
484
|
+
if (message.startsWith("[Feedback]") && chatRepo) {
|
|
485
|
+
try {
|
|
486
|
+
const skillContent = await readFeedbackSkillContent();
|
|
487
|
+
const timeline = await buildSessionTimeline(chatRepo, sessionId);
|
|
488
|
+
promptText = [
|
|
489
|
+
"## Session Feedback Instructions\n",
|
|
490
|
+
skillContent,
|
|
491
|
+
"\n## Current Session Diagnostic Timeline\n",
|
|
492
|
+
timeline,
|
|
493
|
+
"\n---\nThe user has requested a feedback session. Begin the interactive review now.",
|
|
494
|
+
].join("\n");
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
498
|
+
throw new Error(`Feedback system unavailable: ${reason}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
420
501
|
// Send prompt
|
|
421
|
-
const result = await client.prompt({ sessionId, text:
|
|
502
|
+
const result = await client.prompt({ sessionId, text: promptText, modelProvider, modelId, brainType, modelConfig, credentials });
|
|
422
503
|
console.log(`[rpc] prompt sent → sessionId=${result.sessionId}`);
|
|
423
504
|
// Build redaction config from credential payload + model secrets (sanitize outbound WS stream)
|
|
424
505
|
const sensitiveStrings = [];
|
|
@@ -943,7 +1024,6 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
943
1024
|
}
|
|
944
1025
|
// Fallback: read from settings.json mcpServers (CLI / no-DB mode)
|
|
945
1026
|
try {
|
|
946
|
-
const { loadConfig } = await import("../core/config.js");
|
|
947
1027
|
const config = loadConfig();
|
|
948
1028
|
const servers = [];
|
|
949
1029
|
for (const [name, serverConfig] of Object.entries(config.mcpServers ?? {})) {
|
|
@@ -1041,6 +1121,155 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1041
1121
|
await notifyMcpChange();
|
|
1042
1122
|
return { id, enabled: newEnabled };
|
|
1043
1123
|
});
|
|
1124
|
+
// ── Knowledge Base ────────────────────────────────
|
|
1125
|
+
const knowledgeDir = path.resolve(process.cwd(), loadConfig().paths.knowledgeDir);
|
|
1126
|
+
methods.set("kb.list", async (_params, context) => {
|
|
1127
|
+
requireAdmin(context);
|
|
1128
|
+
if (!knowledgeDocRepo)
|
|
1129
|
+
throw new Error("Database not available");
|
|
1130
|
+
const rows = await knowledgeDocRepo.list();
|
|
1131
|
+
return {
|
|
1132
|
+
docs: rows.map((r) => ({
|
|
1133
|
+
id: r.id,
|
|
1134
|
+
name: r.name,
|
|
1135
|
+
filePath: r.filePath,
|
|
1136
|
+
sizeBytes: r.sizeBytes,
|
|
1137
|
+
chunkCount: r.chunkCount,
|
|
1138
|
+
uploadedBy: r.uploadedBy,
|
|
1139
|
+
createdAt: r.createdAt?.toISOString(),
|
|
1140
|
+
updatedAt: r.updatedAt?.toISOString(),
|
|
1141
|
+
})),
|
|
1142
|
+
};
|
|
1143
|
+
});
|
|
1144
|
+
methods.set("kb.get", async (params, context) => {
|
|
1145
|
+
requireAdmin(context);
|
|
1146
|
+
if (!knowledgeDocRepo)
|
|
1147
|
+
throw new Error("Database not available");
|
|
1148
|
+
const id = params.id;
|
|
1149
|
+
if (!id)
|
|
1150
|
+
throw new Error("Missing required param: id");
|
|
1151
|
+
const doc = await knowledgeDocRepo.getById(id);
|
|
1152
|
+
if (!doc)
|
|
1153
|
+
throw new Error("Document not found");
|
|
1154
|
+
// Read file content (path traversal protected)
|
|
1155
|
+
const fullPath = resolveUnderDir(knowledgeDir, doc.filePath);
|
|
1156
|
+
let content = "";
|
|
1157
|
+
if (fs.existsSync(fullPath)) {
|
|
1158
|
+
content = fs.readFileSync(fullPath, "utf-8");
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
console.warn(`[kb-rpc] kb.get: file missing on disk for doc id=${id} path=${doc.filePath}`);
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
id: doc.id,
|
|
1165
|
+
name: doc.name,
|
|
1166
|
+
filePath: doc.filePath,
|
|
1167
|
+
sizeBytes: doc.sizeBytes,
|
|
1168
|
+
chunkCount: doc.chunkCount,
|
|
1169
|
+
uploadedBy: doc.uploadedBy,
|
|
1170
|
+
createdAt: doc.createdAt?.toISOString(),
|
|
1171
|
+
updatedAt: doc.updatedAt?.toISOString(),
|
|
1172
|
+
content,
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
1175
|
+
methods.set("kb.upload", async (params, context) => {
|
|
1176
|
+
requireAdmin(context);
|
|
1177
|
+
if (!knowledgeDocRepo)
|
|
1178
|
+
throw new Error("Database not available");
|
|
1179
|
+
const name = params.name;
|
|
1180
|
+
const content = params.content;
|
|
1181
|
+
if (!name)
|
|
1182
|
+
throw new Error("Missing required param: name");
|
|
1183
|
+
if (!content)
|
|
1184
|
+
throw new Error("Missing required param: content");
|
|
1185
|
+
// Size limit: 5MB
|
|
1186
|
+
const MAX_CONTENT_SIZE = 5 * 1024 * 1024;
|
|
1187
|
+
const sizeBytes = Buffer.byteLength(content, "utf-8");
|
|
1188
|
+
if (sizeBytes > MAX_CONTENT_SIZE) {
|
|
1189
|
+
throw new Error(`Content too large: ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds 5MB limit`);
|
|
1190
|
+
}
|
|
1191
|
+
// Generate unique ID first, then build filename with ID prefix to avoid TOCTOU races
|
|
1192
|
+
const docId = crypto.randomBytes(12).toString("hex");
|
|
1193
|
+
let sanitized = name
|
|
1194
|
+
.replace(/[^a-zA-Z0-9_\-. ]/g, "_")
|
|
1195
|
+
.replace(/\s+/g, "_")
|
|
1196
|
+
.replace(/_+/g, "_")
|
|
1197
|
+
.replace(/^_|_$/g, "");
|
|
1198
|
+
if (!sanitized)
|
|
1199
|
+
sanitized = "document";
|
|
1200
|
+
const baseName = sanitized.endsWith(".md") ? sanitized.slice(0, -3) : sanitized;
|
|
1201
|
+
const filePath = `${baseName}_${docId.slice(0, 8)}.md`;
|
|
1202
|
+
if (!fs.existsSync(knowledgeDir)) {
|
|
1203
|
+
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
1204
|
+
}
|
|
1205
|
+
const fullPath = resolveUnderDir(knowledgeDir, filePath);
|
|
1206
|
+
// Write file, then insert DB record (clean up file on DB failure)
|
|
1207
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
1208
|
+
try {
|
|
1209
|
+
await knowledgeDocRepo.create({
|
|
1210
|
+
id: docId,
|
|
1211
|
+
name,
|
|
1212
|
+
filePath,
|
|
1213
|
+
sizeBytes,
|
|
1214
|
+
uploadedBy: context.auth?.userId,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
// Clean up orphaned file on DB insert failure
|
|
1219
|
+
try {
|
|
1220
|
+
fs.unlinkSync(fullPath);
|
|
1221
|
+
}
|
|
1222
|
+
catch { /* best-effort cleanup */ }
|
|
1223
|
+
throw err;
|
|
1224
|
+
}
|
|
1225
|
+
console.log(`[kb-rpc] kb.upload: name=${name}, file=${filePath}, size=${sizeBytes}, by=${context.auth?.username}`);
|
|
1226
|
+
// Sync indexer and update chunk count
|
|
1227
|
+
if (knowledgeIndexer) {
|
|
1228
|
+
try {
|
|
1229
|
+
await knowledgeIndexer.sync();
|
|
1230
|
+
const chunkCount = knowledgeIndexer.countChunksByFile(filePath);
|
|
1231
|
+
await knowledgeDocRepo.updateChunkCount(docId, chunkCount);
|
|
1232
|
+
}
|
|
1233
|
+
catch (err) {
|
|
1234
|
+
console.warn("[kb-rpc] Knowledge indexer sync failed:", err);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return { id: docId, name };
|
|
1238
|
+
});
|
|
1239
|
+
methods.set("kb.delete", async (params, context) => {
|
|
1240
|
+
requireAdmin(context);
|
|
1241
|
+
if (!knowledgeDocRepo)
|
|
1242
|
+
throw new Error("Database not available");
|
|
1243
|
+
const id = params.id;
|
|
1244
|
+
if (!id)
|
|
1245
|
+
throw new Error("Missing required param: id");
|
|
1246
|
+
const doc = await knowledgeDocRepo.getById(id);
|
|
1247
|
+
if (!doc)
|
|
1248
|
+
throw new Error("Document not found");
|
|
1249
|
+
// Delete metadata first (authoritative source of truth)
|
|
1250
|
+
await knowledgeDocRepo.delete(id);
|
|
1251
|
+
console.log(`[kb-rpc] kb.delete: id=${id}, name=${doc.name}, by=${context.auth?.username}`);
|
|
1252
|
+
// Then delete file from disk (best-effort, path traversal protected)
|
|
1253
|
+
try {
|
|
1254
|
+
const fullPath = resolveUnderDir(knowledgeDir, doc.filePath);
|
|
1255
|
+
if (fs.existsSync(fullPath)) {
|
|
1256
|
+
fs.unlinkSync(fullPath);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
console.warn(`[kb-rpc] kb.delete: file cleanup failed for ${doc.filePath}:`, err);
|
|
1261
|
+
}
|
|
1262
|
+
// Sync indexer to remove orphaned chunks
|
|
1263
|
+
if (knowledgeIndexer) {
|
|
1264
|
+
try {
|
|
1265
|
+
await knowledgeIndexer.sync();
|
|
1266
|
+
}
|
|
1267
|
+
catch (err) {
|
|
1268
|
+
console.warn("[kb-rpc] Knowledge indexer sync failed after delete:", err);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return { ok: true };
|
|
1272
|
+
});
|
|
1044
1273
|
methods.set("chat.steer", async (params, context) => {
|
|
1045
1274
|
const userId = requireAuth(context);
|
|
1046
1275
|
const text = params.text;
|
|
@@ -1479,12 +1708,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1479
1708
|
const userId = requireAuth(context);
|
|
1480
1709
|
const username = context.auth.username;
|
|
1481
1710
|
const name = params.name;
|
|
1482
|
-
const description = params.description;
|
|
1483
1711
|
const type = params.type;
|
|
1484
1712
|
const specs = params.specs;
|
|
1485
1713
|
const rawScripts = params.scripts;
|
|
1486
1714
|
if (!name)
|
|
1487
1715
|
throw new Error("Missing required param: name");
|
|
1716
|
+
// Auto-extract description from specs frontmatter; fall back to explicit param
|
|
1717
|
+
const description = specs
|
|
1718
|
+
? skillWriter.parseFrontmatter(specs).description || params.description
|
|
1719
|
+
: params.description;
|
|
1488
1720
|
// Resolve scripts: if content is missing, copy from user uploads directory
|
|
1489
1721
|
let scripts;
|
|
1490
1722
|
if (rawScripts && rawScripts.length > 0) {
|
|
@@ -1721,28 +1953,34 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1721
1953
|
const specs = params.specs;
|
|
1722
1954
|
const rawScripts = params.scripts;
|
|
1723
1955
|
// Resolve scripts: if content is missing, try DB then uploads dir then existing skill files
|
|
1956
|
+
// NOTE: an explicit empty array means "delete all scripts" — do NOT fall back to existing
|
|
1724
1957
|
let scripts;
|
|
1725
|
-
if (rawScripts
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
if (skillContentRepo) {
|
|
1729
|
-
existingFiles = await skillContentRepo.read(skillId, "working");
|
|
1958
|
+
if (Array.isArray(rawScripts)) {
|
|
1959
|
+
if (rawScripts.length === 0) {
|
|
1960
|
+
scripts = [];
|
|
1730
1961
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
const existing = existingScriptsMap.get(s.name);
|
|
1737
|
-
if (existing)
|
|
1738
|
-
return { name: s.name, content: existing };
|
|
1739
|
-
// Try uploads directory
|
|
1740
|
-
const uploadPath = path.join(uploadsDir, s.name);
|
|
1741
|
-
if (fs.existsSync(uploadPath)) {
|
|
1742
|
-
return { name: s.name, content: fs.readFileSync(uploadPath, "utf-8") };
|
|
1962
|
+
else {
|
|
1963
|
+
const uploadsDir = path.join(skillsDir, "user", userId, "uploads");
|
|
1964
|
+
let existingFiles = null;
|
|
1965
|
+
if (skillContentRepo) {
|
|
1966
|
+
existingFiles = await skillContentRepo.read(skillId, "working");
|
|
1743
1967
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1968
|
+
const existingScriptsMap = new Map((existingFiles?.scripts ?? []).map((s) => [s.name, s.content]));
|
|
1969
|
+
scripts = rawScripts.map((s) => {
|
|
1970
|
+
if (s.content)
|
|
1971
|
+
return { name: s.name, content: s.content };
|
|
1972
|
+
// Try existing skill scripts
|
|
1973
|
+
const existing = existingScriptsMap.get(s.name);
|
|
1974
|
+
if (existing)
|
|
1975
|
+
return { name: s.name, content: existing };
|
|
1976
|
+
// Try uploads directory
|
|
1977
|
+
const uploadPath = path.join(uploadsDir, s.name);
|
|
1978
|
+
if (fs.existsSync(uploadPath)) {
|
|
1979
|
+
return { name: s.name, content: fs.readFileSync(uploadPath, "utf-8") };
|
|
1980
|
+
}
|
|
1981
|
+
throw new Error(`Script "${s.name}" content not found`);
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1746
1984
|
}
|
|
1747
1985
|
// Save content to DB
|
|
1748
1986
|
if (skillContentRepo) {
|
|
@@ -1756,8 +1994,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1756
1994
|
}
|
|
1757
1995
|
// Update DB metadata (name and dirName are immutable after creation)
|
|
1758
1996
|
const updates = {};
|
|
1759
|
-
|
|
1997
|
+
// Auto-extract description from specs frontmatter
|
|
1998
|
+
if (specs) {
|
|
1999
|
+
const extracted = skillWriter.parseFrontmatter(specs).description;
|
|
2000
|
+
if (extracted)
|
|
2001
|
+
updates.description = extracted;
|
|
2002
|
+
}
|
|
2003
|
+
else if (params.description !== undefined) {
|
|
1760
2004
|
updates.description = params.description;
|
|
2005
|
+
}
|
|
1761
2006
|
if (params.type)
|
|
1762
2007
|
updates.type = params.type;
|
|
1763
2008
|
if (params.labels !== undefined) {
|
|
@@ -1800,6 +2045,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1800
2045
|
// Clean up version records
|
|
1801
2046
|
if (skillVersionRepo)
|
|
1802
2047
|
await skillVersionRepo.deleteForSkill(skillId);
|
|
2048
|
+
// Clean up orphaned notifications (approval/contribution requests)
|
|
2049
|
+
if (notifRepo) {
|
|
2050
|
+
await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
|
|
2051
|
+
await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
|
|
2052
|
+
}
|
|
1803
2053
|
// Delete from DB (CASCADE deletes skill_contents)
|
|
1804
2054
|
await skillRepo.deleteById(skillId);
|
|
1805
2055
|
// Notify reload
|
|
@@ -1849,9 +2099,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
1849
2099
|
const meta = await skillRepo.getById(skillId);
|
|
1850
2100
|
if (!meta)
|
|
1851
2101
|
throw new Error("Skill not found");
|
|
1852
|
-
// Personal skills: only author can view diffs
|
|
2102
|
+
// Personal skills: only author or reviewer can view diffs
|
|
1853
2103
|
if (meta.scope === "personal" && meta.authorId !== userId) {
|
|
1854
|
-
|
|
2104
|
+
const isReviewer = context.auth?.username === "admin" ||
|
|
2105
|
+
(permRepo ? await permRepo.hasPermission(userId, "skill_reviewer") : false);
|
|
2106
|
+
if (!isReviewer)
|
|
2107
|
+
throw new Error("Skill not found");
|
|
1855
2108
|
}
|
|
1856
2109
|
/** Build a unified diff string for specs + all scripts between two SkillFiles */
|
|
1857
2110
|
function buildFullDiff(oldFiles, newFiles, oldPrefix, newPrefix) {
|
|
@@ -2342,14 +2595,60 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2342
2595
|
const description = params.description;
|
|
2343
2596
|
const schedule = params.schedule;
|
|
2344
2597
|
const status = params.status ?? "active";
|
|
2598
|
+
const skillId = params.skillId;
|
|
2345
2599
|
const existingId = params.id;
|
|
2346
2600
|
const workspaceId = params.workspaceId;
|
|
2601
|
+
// ── Validation chain ──────────────────────────
|
|
2602
|
+
// 1. Syntax validation
|
|
2603
|
+
parseCronExpression(schedule);
|
|
2604
|
+
// 2. Ownership check on update
|
|
2605
|
+
let existingJob = null;
|
|
2606
|
+
if (existingId) {
|
|
2607
|
+
existingJob = await configRepo.getCronJobById(existingId);
|
|
2608
|
+
if (!existingJob)
|
|
2609
|
+
throw new Error("Job not found");
|
|
2610
|
+
if (existingJob.userId !== userId)
|
|
2611
|
+
throw new Error("Forbidden");
|
|
2612
|
+
}
|
|
2613
|
+
// 3. Interval check (skip if updating without changing schedule)
|
|
2614
|
+
const scheduleChanged = !existingJob || existingJob.schedule !== schedule;
|
|
2615
|
+
if (scheduleChanged) {
|
|
2616
|
+
const { avg, min } = getAverageIntervalMs(schedule, CRON_LIMITS.INTERVAL_SAMPLE_COUNT);
|
|
2617
|
+
if (min < CRON_LIMITS.ABSOLUTE_MIN_GAP_MS) {
|
|
2618
|
+
const floorMin = Math.round(CRON_LIMITS.ABSOLUTE_MIN_GAP_MS / 60_000);
|
|
2619
|
+
throw new Error(`Schedule has burst firing: minimum gap between executions must be at least ${floorMin} minutes`);
|
|
2620
|
+
}
|
|
2621
|
+
if (avg < CRON_LIMITS.MIN_INTERVAL_MS) {
|
|
2622
|
+
const limitMin = Math.round(CRON_LIMITS.MIN_INTERVAL_MS / 60_000);
|
|
2623
|
+
throw new Error(`Schedule interval too short: minimum ${limitMin} minutes between executions`);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
// 4. Active job quota
|
|
2627
|
+
if (status === "active") {
|
|
2628
|
+
const activeCount = await configRepo.countActiveJobsByUser(userId);
|
|
2629
|
+
// If updating an already-active job, it's already counted
|
|
2630
|
+
const alreadyCounted = existingJob?.status === "active" ? 1 : 0;
|
|
2631
|
+
if (activeCount - alreadyCounted >= CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER) {
|
|
2632
|
+
throw new Error(`Active job limit reached (max ${CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER})`);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
// 5. Skill ownership validation
|
|
2636
|
+
if (skillId && skillRepo) {
|
|
2637
|
+
const skill = await skillRepo.getById(skillId);
|
|
2638
|
+
if (!skill)
|
|
2639
|
+
throw new Error(`Skill not found: ${skillId}`);
|
|
2640
|
+
// Allow builtin + team skills for everyone; personal skills only for the author
|
|
2641
|
+
if (skill.scope === "personal" && skill.authorId !== userId) {
|
|
2642
|
+
throw new Error("Forbidden: cannot use another user's personal skill");
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
// ── Persist ───────────────────────────────────
|
|
2347
2646
|
const id = await configRepo.saveCronJob(userId, {
|
|
2348
2647
|
id: existingId,
|
|
2349
2648
|
name,
|
|
2350
2649
|
description,
|
|
2351
2650
|
schedule,
|
|
2352
|
-
skillId
|
|
2651
|
+
skillId,
|
|
2353
2652
|
status,
|
|
2354
2653
|
workspaceId: workspaceId ?? null,
|
|
2355
2654
|
});
|
|
@@ -2360,7 +2659,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2360
2659
|
else {
|
|
2361
2660
|
cronService.addOrUpdate({
|
|
2362
2661
|
id, userId, name, description: description ?? null, schedule, status,
|
|
2363
|
-
skillId:
|
|
2662
|
+
skillId: skillId ?? null, assignedTo: null,
|
|
2364
2663
|
lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
|
|
2365
2664
|
workspaceId: workspaceId ?? null,
|
|
2366
2665
|
});
|
|
@@ -2408,6 +2707,24 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2408
2707
|
throw new Error("Job not found");
|
|
2409
2708
|
if (job.userId !== userId)
|
|
2410
2709
|
throw new Error("Forbidden");
|
|
2710
|
+
// ── Rate-limit checks when activating ──────────
|
|
2711
|
+
if (status === "active" && job.status !== "active") {
|
|
2712
|
+
// Interval check (prevents re-activating a high-frequency job)
|
|
2713
|
+
const { avg, min } = getAverageIntervalMs(job.schedule, CRON_LIMITS.INTERVAL_SAMPLE_COUNT);
|
|
2714
|
+
if (min < CRON_LIMITS.ABSOLUTE_MIN_GAP_MS) {
|
|
2715
|
+
const floorMin = Math.round(CRON_LIMITS.ABSOLUTE_MIN_GAP_MS / 60_000);
|
|
2716
|
+
throw new Error(`Schedule has burst firing: minimum gap between executions must be at least ${floorMin} minutes`);
|
|
2717
|
+
}
|
|
2718
|
+
if (avg < CRON_LIMITS.MIN_INTERVAL_MS) {
|
|
2719
|
+
const limitMin = Math.round(CRON_LIMITS.MIN_INTERVAL_MS / 60_000);
|
|
2720
|
+
throw new Error(`Schedule interval too short: minimum ${limitMin} minutes between executions`);
|
|
2721
|
+
}
|
|
2722
|
+
// Active job quota
|
|
2723
|
+
const activeCount = await configRepo.countActiveJobsByUser(userId);
|
|
2724
|
+
if (activeCount >= CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER) {
|
|
2725
|
+
throw new Error(`Active job limit reached (max ${CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER})`);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2411
2728
|
// Update status
|
|
2412
2729
|
await configRepo.saveCronJob(userId, {
|
|
2413
2730
|
id,
|
|
@@ -2631,6 +2948,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2631
2948
|
// Clean up votes
|
|
2632
2949
|
if (voteRepo)
|
|
2633
2950
|
await voteRepo.deleteForSkill(skillId);
|
|
2951
|
+
// Clean up orphaned notifications (approval/contribution requests for deleted team skill)
|
|
2952
|
+
if (notifRepo) {
|
|
2953
|
+
await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
|
|
2954
|
+
await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
|
|
2955
|
+
}
|
|
2634
2956
|
// Notify author
|
|
2635
2957
|
const authorId = sourceSkill?.authorId ?? meta.authorId;
|
|
2636
2958
|
if (notifRepo && authorId) {
|
|
@@ -2696,11 +3018,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2696
3018
|
throw new Error("Missing required param: id");
|
|
2697
3019
|
if (!skillReviewRepo)
|
|
2698
3020
|
return { reviews: [] };
|
|
2699
|
-
// Personal skills: only author can view reviews
|
|
3021
|
+
// Personal skills: only author or reviewer can view reviews
|
|
2700
3022
|
if (skillRepo) {
|
|
2701
3023
|
const meta = await skillRepo.getById(skillId);
|
|
2702
3024
|
if (meta && meta.scope === "personal" && meta.authorId !== userId) {
|
|
2703
|
-
|
|
3025
|
+
const isReviewer = context.auth?.username === "admin" ||
|
|
3026
|
+
(permRepo ? await permRepo.hasPermission(userId, "skill_reviewer") : false);
|
|
3027
|
+
if (!isReviewer)
|
|
3028
|
+
throw new Error("Skill not found");
|
|
2704
3029
|
}
|
|
2705
3030
|
}
|
|
2706
3031
|
const reviews = await skillReviewRepo.listForSkill(skillId);
|
|
@@ -2734,6 +3059,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2734
3059
|
contributionStatus: "none",
|
|
2735
3060
|
stagingVersion: 0,
|
|
2736
3061
|
});
|
|
3062
|
+
// Clean up orphaned notifications (approval/contribution requests)
|
|
3063
|
+
if (notifRepo) {
|
|
3064
|
+
await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
|
|
3065
|
+
await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
|
|
3066
|
+
}
|
|
2737
3067
|
notifySkillReload(userId);
|
|
2738
3068
|
return { status: "withdrawn", wasNew: false };
|
|
2739
3069
|
});
|
|
@@ -2947,7 +3277,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2947
3277
|
if (!name)
|
|
2948
3278
|
throw new Error("Missing required param: name");
|
|
2949
3279
|
if (type === "kubeconfig") {
|
|
2950
|
-
throw new Error("Kubeconfig credentials are now managed via
|
|
3280
|
+
throw new Error("Kubeconfig credentials are now managed via Clusters. Use userClusterConfig.set instead.");
|
|
2951
3281
|
}
|
|
2952
3282
|
if (!type || !CREDENTIAL_TYPES.includes(type)) {
|
|
2953
3283
|
throw new Error(`Invalid credential type. Must be one of: ${CREDENTIAL_TYPES.join(", ")}`);
|
|
@@ -2998,12 +3328,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
2998
3328
|
return { status: "deleted" };
|
|
2999
3329
|
});
|
|
3000
3330
|
// ─────────────────────────────────────────────────
|
|
3001
|
-
//
|
|
3331
|
+
// Cluster Methods (admin-only)
|
|
3002
3332
|
// ─────────────────────────────────────────────────
|
|
3003
|
-
methods.set("
|
|
3333
|
+
methods.set("cluster.list", async (_params, context) => {
|
|
3004
3334
|
const userId = requireAuth(context);
|
|
3005
|
-
if (!
|
|
3006
|
-
return {
|
|
3335
|
+
if (!clusterRepo)
|
|
3336
|
+
return { clusters: [], isAdmin: false };
|
|
3007
3337
|
const isAdmin = isAdminUser(context);
|
|
3008
3338
|
// Check testOnly
|
|
3009
3339
|
let isTestOnly = false;
|
|
@@ -3011,20 +3341,22 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3011
3341
|
const dbUser = await userRepo.getById(userId);
|
|
3012
3342
|
isTestOnly = dbUser?.testOnly ?? false;
|
|
3013
3343
|
}
|
|
3014
|
-
const
|
|
3015
|
-
const
|
|
3344
|
+
const allClusters = await clusterRepo.list();
|
|
3345
|
+
const visibleClusters = isTestOnly ? allClusters.filter((e) => e.isTest) : allClusters;
|
|
3016
3346
|
// Fetch user's kubeconfig status
|
|
3017
|
-
const userConfigs =
|
|
3018
|
-
const configMap = new Map(userConfigs.map((c) => [c.
|
|
3347
|
+
const userConfigs = userClusterConfigRepo ? await userClusterConfigRepo.listForUser(userId) : [];
|
|
3348
|
+
const configMap = new Map(userConfigs.map((c) => [c.clusterId, c]));
|
|
3019
3349
|
return {
|
|
3020
3350
|
isAdmin,
|
|
3021
|
-
|
|
3351
|
+
clusters: visibleClusters.map((e) => ({
|
|
3022
3352
|
id: e.id,
|
|
3023
3353
|
name: e.name,
|
|
3354
|
+
infraContext: e.infraContext ?? null,
|
|
3024
3355
|
isTest: e.isTest,
|
|
3025
3356
|
apiServer: e.apiServer,
|
|
3026
3357
|
allowedServers: e.allowedServers ? e.allowedServers.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
3027
3358
|
hasDefaultKubeconfig: !!e.defaultKubeconfig,
|
|
3359
|
+
debugImage: e.debugImage ?? null,
|
|
3028
3360
|
hasUserKubeconfig: configMap.has(e.id),
|
|
3029
3361
|
userConfigUpdatedAt: configMap.get(e.id)?.updatedAt?.toISOString?.() ?? configMap.get(e.id)?.updatedAt ?? null,
|
|
3030
3362
|
createdBy: e.createdBy,
|
|
@@ -3033,16 +3365,18 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3033
3365
|
})),
|
|
3034
3366
|
};
|
|
3035
3367
|
});
|
|
3036
|
-
methods.set("
|
|
3368
|
+
methods.set("cluster.create", async (params, context) => {
|
|
3037
3369
|
const userId = requireAdmin(context);
|
|
3038
|
-
if (!
|
|
3370
|
+
if (!clusterRepo)
|
|
3039
3371
|
throw new Error("Database not available");
|
|
3040
3372
|
const name = params.name;
|
|
3373
|
+
const infraContext = params.infraContext ?? null;
|
|
3041
3374
|
const isTest = params.isTest;
|
|
3042
3375
|
const apiServer = params.apiServer;
|
|
3043
3376
|
const rawAllowedServers = params.allowedServers;
|
|
3044
3377
|
const allowedServers = Array.isArray(rawAllowedServers) ? rawAllowedServers.join(", ") : rawAllowedServers;
|
|
3045
3378
|
const defaultKubeconfig = params.defaultKubeconfig;
|
|
3379
|
+
const debugImage = params.debugImage?.trim() || null;
|
|
3046
3380
|
if (!name)
|
|
3047
3381
|
throw new Error("Missing required param: name");
|
|
3048
3382
|
if (!apiServer)
|
|
@@ -3062,20 +3396,21 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3062
3396
|
if (defaultKubeconfig && !isTest) {
|
|
3063
3397
|
throw new Error("defaultKubeconfig can only be set for test environments");
|
|
3064
3398
|
}
|
|
3065
|
-
const id = await
|
|
3399
|
+
const id = await clusterRepo.save({ name, infraContext, isTest, apiServer, allowedServers: allowedServers || null, defaultKubeconfig: defaultKubeconfig ?? null, debugImage }, userId);
|
|
3066
3400
|
return { id, name };
|
|
3067
3401
|
});
|
|
3068
|
-
methods.set("
|
|
3402
|
+
methods.set("cluster.update", async (params, context) => {
|
|
3069
3403
|
requireAdmin(context);
|
|
3070
|
-
if (!
|
|
3404
|
+
if (!clusterRepo)
|
|
3071
3405
|
throw new Error("Database not available");
|
|
3072
3406
|
const id = params.id;
|
|
3073
3407
|
if (!id)
|
|
3074
3408
|
throw new Error("Missing required param: id");
|
|
3075
|
-
const existing = await
|
|
3409
|
+
const existing = await clusterRepo.getById(id);
|
|
3076
3410
|
if (!existing)
|
|
3077
|
-
throw new Error("
|
|
3411
|
+
throw new Error("Cluster not found");
|
|
3078
3412
|
const name = params.name ?? existing.name;
|
|
3413
|
+
const infraContext = params.infraContext !== undefined ? params.infraContext : existing.infraContext;
|
|
3079
3414
|
const apiServer = params.apiServer ?? existing.apiServer;
|
|
3080
3415
|
const isTest = params.isTest !== undefined ? params.isTest : existing.isTest;
|
|
3081
3416
|
const rawAllowed = params.allowedServers;
|
|
@@ -3083,6 +3418,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3083
3418
|
? (Array.isArray(rawAllowed) ? rawAllowed.join(", ") : rawAllowed)
|
|
3084
3419
|
: existing.allowedServers;
|
|
3085
3420
|
let defaultKubeconfig = params.defaultKubeconfig !== undefined ? params.defaultKubeconfig : existing.defaultKubeconfig;
|
|
3421
|
+
const debugImage = params.debugImage !== undefined ? (params.debugImage?.trim() || null) : existing.debugImage;
|
|
3086
3422
|
if (!apiServer?.trim()) {
|
|
3087
3423
|
throw new Error("apiServer must be a non-empty string");
|
|
3088
3424
|
}
|
|
@@ -3106,10 +3442,10 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3106
3442
|
throw new Error("defaultKubeconfig can only be set for test environments");
|
|
3107
3443
|
}
|
|
3108
3444
|
const oldApiServer = existing.apiServer;
|
|
3109
|
-
await
|
|
3445
|
+
await clusterRepo.save({ id, name, infraContext, isTest, apiServer, allowedServers, defaultKubeconfig, debugImage });
|
|
3110
3446
|
// If apiServer changed, invalidate mismatched user kubeconfigs
|
|
3111
|
-
if (
|
|
3112
|
-
const fullConfigs = await
|
|
3447
|
+
if (userClusterConfigRepo && apiServer !== oldApiServer) {
|
|
3448
|
+
const fullConfigs = await userClusterConfigRepo.listFullForCluster(id);
|
|
3113
3449
|
const affectedUserIds = new Set();
|
|
3114
3450
|
for (const cfg of fullConfigs) {
|
|
3115
3451
|
try {
|
|
@@ -3118,13 +3454,13 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3118
3454
|
const servers = clusters.map((c) => c.cluster?.server).filter(Boolean);
|
|
3119
3455
|
const matches = servers.some((s) => apiServerHostMatch(s, apiServer));
|
|
3120
3456
|
if (!matches) {
|
|
3121
|
-
await
|
|
3457
|
+
await userClusterConfigRepo.remove(cfg.userId, id);
|
|
3122
3458
|
affectedUserIds.add(cfg.userId);
|
|
3123
3459
|
}
|
|
3124
3460
|
}
|
|
3125
3461
|
catch {
|
|
3126
3462
|
// If kubeconfig can't be parsed, remove it as invalid
|
|
3127
|
-
await
|
|
3463
|
+
await userClusterConfigRepo.remove(cfg.userId, id);
|
|
3128
3464
|
affectedUserIds.add(cfg.userId);
|
|
3129
3465
|
}
|
|
3130
3466
|
}
|
|
@@ -3135,25 +3471,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3135
3471
|
}
|
|
3136
3472
|
return { status: "updated" };
|
|
3137
3473
|
});
|
|
3138
|
-
methods.set("
|
|
3474
|
+
methods.set("cluster.delete", async (params, context) => {
|
|
3139
3475
|
requireAdmin(context);
|
|
3140
|
-
if (!
|
|
3476
|
+
if (!clusterRepo)
|
|
3141
3477
|
throw new Error("Database not available");
|
|
3142
3478
|
const id = params.id;
|
|
3143
3479
|
if (!id)
|
|
3144
3480
|
throw new Error("Missing required param: id");
|
|
3145
|
-
const existing = await
|
|
3481
|
+
const existing = await clusterRepo.getById(id);
|
|
3146
3482
|
if (!existing)
|
|
3147
|
-
throw new Error("
|
|
3483
|
+
throw new Error("Cluster not found");
|
|
3148
3484
|
// Collect affected users before cleanup
|
|
3149
3485
|
const affectedUserIds = new Set();
|
|
3150
|
-
if (
|
|
3151
|
-
const envConfigs = await
|
|
3486
|
+
if (userClusterConfigRepo) {
|
|
3487
|
+
const envConfigs = await userClusterConfigRepo.listForCluster(id);
|
|
3152
3488
|
for (const c of envConfigs)
|
|
3153
3489
|
affectedUserIds.add(c.userId);
|
|
3154
|
-
await
|
|
3490
|
+
await userClusterConfigRepo.removeAllForCluster(id);
|
|
3155
3491
|
}
|
|
3156
|
-
await
|
|
3492
|
+
await clusterRepo.delete(id);
|
|
3157
3493
|
// Push updated credentials to all affected users
|
|
3158
3494
|
for (const uid of affectedUserIds) {
|
|
3159
3495
|
pushCredentialsToUser(uid);
|
|
@@ -3161,54 +3497,54 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3161
3497
|
return { status: "deleted" };
|
|
3162
3498
|
});
|
|
3163
3499
|
// ─────────────────────────────────────────────────
|
|
3164
|
-
// User
|
|
3500
|
+
// User Cluster Config Methods (kubeconfig upload)
|
|
3165
3501
|
// ─────────────────────────────────────────────────
|
|
3166
|
-
methods.set("
|
|
3502
|
+
methods.set("userClusterConfig.list", async (_params, context) => {
|
|
3167
3503
|
const userId = requireAuth(context);
|
|
3168
|
-
if (!
|
|
3504
|
+
if (!clusterRepo || !userClusterConfigRepo)
|
|
3169
3505
|
return { configs: [] };
|
|
3170
|
-
// Fetch all
|
|
3171
|
-
const
|
|
3172
|
-
const userConfigs = await
|
|
3506
|
+
// Fetch all clusters and user's configs
|
|
3507
|
+
const allClusters = await clusterRepo.list();
|
|
3508
|
+
const userConfigs = await userClusterConfigRepo.listForUser(userId);
|
|
3173
3509
|
// Check if user is testOnly
|
|
3174
3510
|
let isTestOnly = false;
|
|
3175
3511
|
if (userRepo) {
|
|
3176
3512
|
const dbUser = await userRepo.getById(userId);
|
|
3177
3513
|
isTestOnly = dbUser?.testOnly ?? false;
|
|
3178
3514
|
}
|
|
3179
|
-
// Filter out production
|
|
3180
|
-
const
|
|
3181
|
-
const configMap = new Map(userConfigs.map((c) => [c.
|
|
3515
|
+
// Filter out production clusters for testOnly users
|
|
3516
|
+
const visibleClusters = isTestOnly ? allClusters.filter((e) => e.isTest) : allClusters;
|
|
3517
|
+
const configMap = new Map(userConfigs.map((c) => [c.clusterId, c]));
|
|
3182
3518
|
return {
|
|
3183
|
-
configs:
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
isTest:
|
|
3187
|
-
apiServer:
|
|
3188
|
-
hasKubeconfig: configMap.has(
|
|
3189
|
-
updatedAt: configMap.get(
|
|
3519
|
+
configs: visibleClusters.map((cls) => ({
|
|
3520
|
+
clusterId: cls.id,
|
|
3521
|
+
clusterName: cls.name,
|
|
3522
|
+
isTest: cls.isTest,
|
|
3523
|
+
apiServer: cls.apiServer,
|
|
3524
|
+
hasKubeconfig: configMap.has(cls.id),
|
|
3525
|
+
updatedAt: configMap.get(cls.id)?.updatedAt ?? null,
|
|
3190
3526
|
})),
|
|
3191
3527
|
};
|
|
3192
3528
|
});
|
|
3193
|
-
methods.set("
|
|
3529
|
+
methods.set("userClusterConfig.set", async (params, context) => {
|
|
3194
3530
|
const userId = requireAuth(context);
|
|
3195
|
-
if (!
|
|
3531
|
+
if (!clusterRepo || !userClusterConfigRepo)
|
|
3196
3532
|
throw new Error("Database not available");
|
|
3197
|
-
const
|
|
3533
|
+
const clusterId = params.clusterId;
|
|
3198
3534
|
const kubeconfig = params.kubeconfig;
|
|
3199
|
-
if (!
|
|
3200
|
-
throw new Error("Missing required param:
|
|
3535
|
+
if (!clusterId)
|
|
3536
|
+
throw new Error("Missing required param: clusterId");
|
|
3201
3537
|
if (!kubeconfig)
|
|
3202
3538
|
throw new Error("Missing required param: kubeconfig");
|
|
3203
|
-
// Fetch
|
|
3204
|
-
const
|
|
3205
|
-
if (!
|
|
3206
|
-
throw new Error("
|
|
3539
|
+
// Fetch cluster
|
|
3540
|
+
const cluster = await clusterRepo.getById(clusterId);
|
|
3541
|
+
if (!cluster)
|
|
3542
|
+
throw new Error("Cluster not found");
|
|
3207
3543
|
// testOnly user check
|
|
3208
3544
|
if (userRepo) {
|
|
3209
3545
|
const dbUser = await userRepo.getById(userId);
|
|
3210
|
-
if (dbUser?.testOnly && !
|
|
3211
|
-
throw new Error("Test-only users cannot configure production
|
|
3546
|
+
if (dbUser?.testOnly && !cluster.isTest) {
|
|
3547
|
+
throw new Error("Test-only users cannot configure production clusters");
|
|
3212
3548
|
}
|
|
3213
3549
|
}
|
|
3214
3550
|
// Parse and validate kubeconfig YAML
|
|
@@ -3220,23 +3556,23 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3220
3556
|
throw new Error("Invalid kubeconfig: YAML parse error");
|
|
3221
3557
|
}
|
|
3222
3558
|
// Validate apiServer appears in kubeconfig clusters
|
|
3223
|
-
const
|
|
3224
|
-
const servers =
|
|
3225
|
-
if (!servers.some((s) => apiServerHostMatch(s,
|
|
3226
|
-
throw new Error(`Kubeconfig does not contain a cluster matching apiServer "${
|
|
3559
|
+
const kubeClusters = parsed?.clusters ?? [];
|
|
3560
|
+
const servers = kubeClusters.map((c) => c.cluster?.server).filter(Boolean);
|
|
3561
|
+
if (!servers.some((s) => apiServerHostMatch(s, cluster.apiServer))) {
|
|
3562
|
+
throw new Error(`Kubeconfig does not contain a cluster matching apiServer "${cluster.apiServer}"`);
|
|
3227
3563
|
}
|
|
3228
|
-
await
|
|
3564
|
+
await userClusterConfigRepo.set(userId, clusterId, kubeconfig);
|
|
3229
3565
|
pushCredentialsToUser(userId);
|
|
3230
3566
|
return { status: "saved" };
|
|
3231
3567
|
});
|
|
3232
|
-
methods.set("
|
|
3568
|
+
methods.set("userClusterConfig.remove", async (params, context) => {
|
|
3233
3569
|
const userId = requireAuth(context);
|
|
3234
|
-
if (!
|
|
3570
|
+
if (!userClusterConfigRepo)
|
|
3235
3571
|
throw new Error("Database not available");
|
|
3236
|
-
const
|
|
3237
|
-
if (!
|
|
3238
|
-
throw new Error("Missing required param:
|
|
3239
|
-
await
|
|
3572
|
+
const clusterId = params.clusterId;
|
|
3573
|
+
if (!clusterId)
|
|
3574
|
+
throw new Error("Missing required param: clusterId");
|
|
3575
|
+
await userClusterConfigRepo.remove(userId, clusterId);
|
|
3240
3576
|
pushCredentialsToUser(userId);
|
|
3241
3577
|
return { status: "removed" };
|
|
3242
3578
|
});
|
|
@@ -3313,14 +3649,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3313
3649
|
throw new Error("Test-only users cannot create production workspaces");
|
|
3314
3650
|
}
|
|
3315
3651
|
}
|
|
3316
|
-
// If changing to "test", verify all bound
|
|
3317
|
-
if (params.envType === "test" &&
|
|
3318
|
-
const
|
|
3319
|
-
if (
|
|
3320
|
-
const
|
|
3321
|
-
const nonTest =
|
|
3652
|
+
// If changing to "test", verify all bound clusters are test
|
|
3653
|
+
if (params.envType === "test" && clusterRepo && workspaceRepo) {
|
|
3654
|
+
const boundClusterIds = await workspaceRepo.getClusters(id);
|
|
3655
|
+
if (boundClusterIds.length > 0) {
|
|
3656
|
+
const boundClusters = await clusterRepo.listByIds(boundClusterIds);
|
|
3657
|
+
const nonTest = boundClusters.filter((e) => !e.isTest);
|
|
3322
3658
|
if (nonTest.length > 0) {
|
|
3323
|
-
throw new Error(`Cannot change to test type: workspace has ${nonTest.length} non-test
|
|
3659
|
+
throw new Error(`Cannot change to test type: workspace has ${nonTest.length} non-test cluster(s) bound. Unbind them first.`);
|
|
3324
3660
|
}
|
|
3325
3661
|
}
|
|
3326
3662
|
}
|
|
@@ -3357,25 +3693,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3357
3693
|
const ws = await workspaceRepo.getById(id);
|
|
3358
3694
|
if (!ws || ws.userId !== userId)
|
|
3359
3695
|
throw new Error("Workspace not found");
|
|
3360
|
-
const [wsSkills, wsTools, wsCreds,
|
|
3696
|
+
const [wsSkills, wsTools, wsCreds, wsClusterIds] = await Promise.all([
|
|
3361
3697
|
workspaceRepo.getSkills(id),
|
|
3362
3698
|
workspaceRepo.getTools(id),
|
|
3363
3699
|
workspaceRepo.getCredentials(id),
|
|
3364
|
-
workspaceRepo.
|
|
3700
|
+
workspaceRepo.getClusters(id),
|
|
3365
3701
|
]);
|
|
3366
|
-
// Fetch full
|
|
3367
|
-
let
|
|
3368
|
-
if (
|
|
3369
|
-
const
|
|
3370
|
-
|
|
3702
|
+
// Fetch full cluster details for bound clusters
|
|
3703
|
+
let clusterDetails = [];
|
|
3704
|
+
if (clusterRepo && wsClusterIds.length > 0) {
|
|
3705
|
+
const cls = await clusterRepo.listByIds(wsClusterIds);
|
|
3706
|
+
clusterDetails = cls.map((e) => ({ id: e.id, name: e.name, isTest: e.isTest, apiServer: e.apiServer }));
|
|
3371
3707
|
}
|
|
3372
3708
|
return {
|
|
3373
3709
|
workspace: ws,
|
|
3374
3710
|
skills: wsSkills,
|
|
3375
3711
|
tools: wsTools,
|
|
3376
3712
|
credentials: wsCreds,
|
|
3377
|
-
|
|
3378
|
-
|
|
3713
|
+
clusters: wsClusterIds,
|
|
3714
|
+
clusterDetails,
|
|
3379
3715
|
};
|
|
3380
3716
|
});
|
|
3381
3717
|
methods.set("workspace.setSkills", async (params, context) => {
|
|
@@ -3446,44 +3782,44 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3446
3782
|
pushCredentialsToUser(userId);
|
|
3447
3783
|
return { status: "updated" };
|
|
3448
3784
|
});
|
|
3449
|
-
methods.set("workspace.
|
|
3785
|
+
methods.set("workspace.setClusters", async (params, context) => {
|
|
3450
3786
|
const userId = requireAuth(context);
|
|
3451
3787
|
if (!workspaceRepo)
|
|
3452
3788
|
throw new Error("Database not available");
|
|
3453
3789
|
const workspaceId = params.workspaceId;
|
|
3454
|
-
const
|
|
3455
|
-
if (!workspaceId || !Array.isArray(
|
|
3790
|
+
const clusterIds = params.clusterIds;
|
|
3791
|
+
if (!workspaceId || !Array.isArray(clusterIds))
|
|
3456
3792
|
throw new Error("Missing required params");
|
|
3457
3793
|
const ws = await workspaceRepo.getById(workspaceId);
|
|
3458
3794
|
if (!ws || ws.userId !== userId)
|
|
3459
3795
|
throw new Error("Workspace not found");
|
|
3460
|
-
// Validate: if workspace is test, all bound
|
|
3461
|
-
if (
|
|
3462
|
-
throw new Error("
|
|
3463
|
-
}
|
|
3464
|
-
if (
|
|
3465
|
-
const
|
|
3466
|
-
if (
|
|
3467
|
-
throw new Error("One or more
|
|
3796
|
+
// Validate: if workspace is test, all bound clusters must be test
|
|
3797
|
+
if (clusterIds.length > 0 && !clusterRepo) {
|
|
3798
|
+
throw new Error("Cluster database not available");
|
|
3799
|
+
}
|
|
3800
|
+
if (clusterIds.length > 0 && clusterRepo) {
|
|
3801
|
+
const cls = await clusterRepo.listByIds(clusterIds);
|
|
3802
|
+
if (cls.length !== clusterIds.length) {
|
|
3803
|
+
throw new Error("One or more clusters not found");
|
|
3468
3804
|
}
|
|
3469
3805
|
if (ws.envType === "test") {
|
|
3470
|
-
const nonTest =
|
|
3806
|
+
const nonTest = cls.filter((e) => !e.isTest);
|
|
3471
3807
|
if (nonTest.length > 0) {
|
|
3472
|
-
throw new Error("Test workspaces can only bind test
|
|
3808
|
+
throw new Error("Test workspaces can only bind test clusters");
|
|
3473
3809
|
}
|
|
3474
3810
|
}
|
|
3475
3811
|
// testOnly user check
|
|
3476
3812
|
if (userRepo) {
|
|
3477
3813
|
const dbUser = await userRepo.getById(userId);
|
|
3478
3814
|
if (dbUser?.testOnly) {
|
|
3479
|
-
const nonTest =
|
|
3815
|
+
const nonTest = cls.filter((e) => !e.isTest);
|
|
3480
3816
|
if (nonTest.length > 0) {
|
|
3481
|
-
throw new Error("Test-only users cannot bind production
|
|
3817
|
+
throw new Error("Test-only users cannot bind production clusters");
|
|
3482
3818
|
}
|
|
3483
3819
|
}
|
|
3484
3820
|
}
|
|
3485
3821
|
}
|
|
3486
|
-
await workspaceRepo.
|
|
3822
|
+
await workspaceRepo.setClusters(workspaceId, clusterIds);
|
|
3487
3823
|
// Push updated credentials to running AgentBox for this workspace
|
|
3488
3824
|
pushCredentialsToUser(userId);
|
|
3489
3825
|
return { status: "updated" };
|
|
@@ -3512,7 +3848,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3512
3848
|
/**
|
|
3513
3849
|
* Build credential payload for a workspace.
|
|
3514
3850
|
*
|
|
3515
|
-
* Kubeconfigs: sourced from
|
|
3851
|
+
* Kubeconfigs: sourced from cluster-bound userClusterConfigs (NOT credentials table).
|
|
3516
3852
|
* Other credentials: from credentials table, filtered by workspace envType.
|
|
3517
3853
|
* - prod workspace: all workspace-linked credentials
|
|
3518
3854
|
* - test workspace: NO non-kubeconfig credentials (SSH, API tokens hidden)
|
|
@@ -3520,11 +3856,6 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3520
3856
|
* Returns data only — does NOT write to disk. Agentbox materializes files locally.
|
|
3521
3857
|
*/
|
|
3522
3858
|
async function buildCredentialPayload(userId, workspaceId, isDefault) {
|
|
3523
|
-
// Ensure user agent-data directory exists (used as subPath mount for user data)
|
|
3524
|
-
const agentDataDir = path.join(skillsDir, "user", userId, "agent-data");
|
|
3525
|
-
if (!fs.existsSync(agentDataDir)) {
|
|
3526
|
-
fs.mkdirSync(agentDataDir, { recursive: true });
|
|
3527
|
-
}
|
|
3528
3859
|
const manifest = [];
|
|
3529
3860
|
const files = [];
|
|
3530
3861
|
// ── Step 1: Determine workspace envType ──
|
|
@@ -3534,55 +3865,59 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3534
3865
|
if (ws)
|
|
3535
3866
|
envType = ws.envType ?? "prod";
|
|
3536
3867
|
}
|
|
3537
|
-
// ── Step 2: Kubeconfigs from
|
|
3538
|
-
if (
|
|
3539
|
-
// Default workspace: all
|
|
3540
|
-
let
|
|
3868
|
+
// ── Step 2: Kubeconfigs from clusters ──
|
|
3869
|
+
if (clusterRepo && userClusterConfigRepo) {
|
|
3870
|
+
// Default workspace: all clusters; non-default: only workspace-bound
|
|
3871
|
+
let clusterList;
|
|
3541
3872
|
if (isDefault) {
|
|
3542
|
-
|
|
3873
|
+
clusterList = await clusterRepo.list();
|
|
3543
3874
|
}
|
|
3544
3875
|
else if (workspaceRepo) {
|
|
3545
|
-
const
|
|
3546
|
-
|
|
3876
|
+
const boundClusterIds = await workspaceRepo.getClusters(workspaceId);
|
|
3877
|
+
clusterList = boundClusterIds.length > 0 ? await clusterRepo.listByIds(boundClusterIds) : [];
|
|
3547
3878
|
}
|
|
3548
3879
|
else {
|
|
3549
|
-
|
|
3880
|
+
clusterList = [];
|
|
3550
3881
|
}
|
|
3551
|
-
if (
|
|
3552
|
-
for (const
|
|
3553
|
-
// Runtime filter: test workspace skips prod
|
|
3554
|
-
if (envType === "test" && !
|
|
3882
|
+
if (clusterList.length > 0) {
|
|
3883
|
+
for (const cls of clusterList) {
|
|
3884
|
+
// Runtime filter: test workspace skips prod clusters
|
|
3885
|
+
if (envType === "test" && !cls.isTest)
|
|
3555
3886
|
continue;
|
|
3556
|
-
// Get user's kubeconfig for this
|
|
3557
|
-
const userConfig = await
|
|
3887
|
+
// Get user's kubeconfig for this cluster
|
|
3888
|
+
const userConfig = await userClusterConfigRepo.get(userId, cls.id);
|
|
3558
3889
|
let kubeconfigContent = userConfig?.kubeconfig ?? null;
|
|
3559
|
-
// Fallback to defaultKubeconfig for test
|
|
3560
|
-
if (!kubeconfigContent &&
|
|
3561
|
-
kubeconfigContent =
|
|
3890
|
+
// Fallback to defaultKubeconfig for test clusters
|
|
3891
|
+
if (!kubeconfigContent && cls.isTest && cls.defaultKubeconfig) {
|
|
3892
|
+
kubeconfigContent = cls.defaultKubeconfig;
|
|
3562
3893
|
}
|
|
3563
3894
|
if (kubeconfigContent) {
|
|
3564
|
-
const safeName =
|
|
3895
|
+
const safeName = cls.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3565
3896
|
const filename = `${safeName}.kubeconfig`;
|
|
3566
3897
|
files.push({ name: filename, content: kubeconfigContent });
|
|
3567
3898
|
const fileNames = [filename];
|
|
3568
3899
|
let metadata;
|
|
3569
3900
|
try {
|
|
3570
3901
|
const kc = yaml.load(kubeconfigContent);
|
|
3571
|
-
const
|
|
3902
|
+
const kcClusters = kc?.clusters ?? [];
|
|
3572
3903
|
const contexts = kc?.contexts ?? [];
|
|
3573
3904
|
metadata = {
|
|
3574
|
-
clusters:
|
|
3905
|
+
clusters: kcClusters.map((c) => ({ name: c.name, server: c.cluster?.server })),
|
|
3575
3906
|
contexts: contexts.map((c) => ({ name: c.name, cluster: c.context?.cluster, namespace: c.context?.namespace })),
|
|
3576
3907
|
currentContext: kc?.["current-context"],
|
|
3908
|
+
...(cls.debugImage ? { debugImage: cls.debugImage } : {}),
|
|
3577
3909
|
};
|
|
3578
3910
|
}
|
|
3579
3911
|
catch {
|
|
3580
|
-
// ignore parse errors
|
|
3912
|
+
// ignore parse errors — still attach debugImage if available
|
|
3913
|
+
if (cls.debugImage) {
|
|
3914
|
+
metadata = { debugImage: cls.debugImage };
|
|
3915
|
+
}
|
|
3581
3916
|
}
|
|
3582
3917
|
manifest.push({
|
|
3583
|
-
name:
|
|
3918
|
+
name: cls.name,
|
|
3584
3919
|
type: "kubeconfig",
|
|
3585
|
-
description: `Kubeconfig for
|
|
3920
|
+
description: cls.infraContext || `Kubeconfig for cluster: ${cls.name}`,
|
|
3586
3921
|
files: fileNames,
|
|
3587
3922
|
...(metadata ? { metadata } : {}),
|
|
3588
3923
|
});
|
|
@@ -3768,12 +4103,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
|
|
|
3768
4103
|
credentials[c.type] = (credentials[c.type] || 0) + 1;
|
|
3769
4104
|
}
|
|
3770
4105
|
}
|
|
3771
|
-
if (
|
|
3772
|
-
const allEnvs = await
|
|
4106
|
+
if (clusterRepo && userClusterConfigRepo) {
|
|
4107
|
+
const allEnvs = await clusterRepo.list();
|
|
3773
4108
|
let kubeconfigCount = 0;
|
|
3774
4109
|
for (const env of allEnvs) {
|
|
3775
4110
|
// Count if user has a personal kubeconfig, OR if it's a test env with a default kubeconfig
|
|
3776
|
-
const userConfig = await
|
|
4111
|
+
const userConfig = await userClusterConfigRepo.get(userId, env.id);
|
|
3777
4112
|
if (userConfig?.kubeconfig || (env.isTest && env.defaultKubeconfig)) {
|
|
3778
4113
|
kubeconfigCount++;
|
|
3779
4114
|
}
|