@frumu/tandem-panel 0.4.14 → 0.4.16
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/.env.example +25 -1
- package/README.md +17 -8
- package/bin/cli.js +3 -1
- package/bin/setup.js +260 -9
- package/dist/assets/index-C3pGFPtZ.css +1 -0
- package/dist/assets/index-fje-pNlH.js +2474 -0
- package/dist/assets/{motion-BCvrfAt1.js → motion-m8lxAefi.js} +1 -1
- package/dist/assets/preact-vendor-CWXGD9A4.js +1 -0
- package/dist/assets/{react-query-wD0mx2Xi.js → react-query-tz3oGfUr.js} +1 -1
- package/dist/assets/vendor-Bw8uHUnC.js +180 -0
- package/dist/index.html +6 -6
- package/lib/setup/control-panel-config.js +196 -0
- package/package.json +9 -3
- package/server/routes/aca.js +97 -0
- package/server/routes/capabilities.js +198 -0
- package/server/routes/control-panel-config.js +106 -0
- package/server/routes/swarm.js +18 -1
- package/src/generated/agent-catalog.json +2254 -0
- package/dist/assets/index-DJVNgAiY.css +0 -1
- package/dist/assets/index-xmcHHgpI.js +0 -2460
- package/dist/assets/preact-vendor-jo0muZ28.js +0 -1
- package/dist/assets/vendor-BB3fzNns.js +0 -180
package/dist/index.html
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rubik:wght@500;700;800&family=Manrope:wght@400;500;600;700&display=swap"
|
|
12
12
|
rel="stylesheet"
|
|
13
13
|
/>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/preact-vendor-
|
|
16
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/assets/react-query-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/motion-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-fje-pNlH.js"></script>
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/preact-vendor-CWXGD9A4.js">
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-Bw8uHUnC.js">
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/assets/react-query-tz3oGfUr.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/motion-m8lxAefi.js">
|
|
19
19
|
<link rel="modulepreload" crossorigin href="/assets/markdown-DMcD1LHz.js">
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C3pGFPtZ.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONTROL_PANEL_CONFIG = {
|
|
6
|
+
version: 1,
|
|
7
|
+
control_panel: {
|
|
8
|
+
mode: "auto",
|
|
9
|
+
aca_compact_nav: true,
|
|
10
|
+
},
|
|
11
|
+
agent: {
|
|
12
|
+
name: "ACA",
|
|
13
|
+
dry_run: false,
|
|
14
|
+
},
|
|
15
|
+
tandem: {
|
|
16
|
+
base_url: "http://127.0.0.1:39733",
|
|
17
|
+
token_env: "TANDEM_API_TOKEN",
|
|
18
|
+
token_file: "secrets/tandem_api_token",
|
|
19
|
+
required_version: "",
|
|
20
|
+
startup_mode: "reuse_or_start",
|
|
21
|
+
update_policy: "notify",
|
|
22
|
+
engine_command: "scripts/tandem-engine-serve.sh",
|
|
23
|
+
},
|
|
24
|
+
task_source: {
|
|
25
|
+
type: "kanban_board",
|
|
26
|
+
owner: "",
|
|
27
|
+
repo: "",
|
|
28
|
+
project: "",
|
|
29
|
+
item: "",
|
|
30
|
+
url: "",
|
|
31
|
+
path: "config/board.yaml",
|
|
32
|
+
prompt: "",
|
|
33
|
+
source_name: "",
|
|
34
|
+
card_id: "",
|
|
35
|
+
payload: {},
|
|
36
|
+
},
|
|
37
|
+
repository: {
|
|
38
|
+
path: "",
|
|
39
|
+
slug: "",
|
|
40
|
+
clone_url: "",
|
|
41
|
+
default_branch: "main",
|
|
42
|
+
worktree_root: "",
|
|
43
|
+
remote_name: "origin",
|
|
44
|
+
},
|
|
45
|
+
provider: {
|
|
46
|
+
id: "openai",
|
|
47
|
+
model: "gpt-4.1-mini",
|
|
48
|
+
base_url: "",
|
|
49
|
+
fallback_provider: "",
|
|
50
|
+
fallback_model: "",
|
|
51
|
+
},
|
|
52
|
+
execution: {
|
|
53
|
+
backend: "auto",
|
|
54
|
+
},
|
|
55
|
+
swarm: {
|
|
56
|
+
enabled: false,
|
|
57
|
+
shared_model: false,
|
|
58
|
+
max_workers: 3,
|
|
59
|
+
max_retries: 1,
|
|
60
|
+
manager: { provider: "", model: "" },
|
|
61
|
+
worker: { provider: "", model: "" },
|
|
62
|
+
reviewer: { provider: "", model: "" },
|
|
63
|
+
tester: { provider: "", model: "" },
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
root: "runs",
|
|
67
|
+
},
|
|
68
|
+
github_mcp: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
url: "https://api.githubcopilot.com/mcp/",
|
|
71
|
+
toolsets: "default,projects",
|
|
72
|
+
scope: "intake_finalize",
|
|
73
|
+
remote_sync: "status_comment",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function deepMerge(base, overlay) {
|
|
78
|
+
if (Array.isArray(base) && Array.isArray(overlay)) {
|
|
79
|
+
return overlay.slice();
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
base &&
|
|
83
|
+
overlay &&
|
|
84
|
+
typeof base === "object" &&
|
|
85
|
+
typeof overlay === "object" &&
|
|
86
|
+
!Array.isArray(base) &&
|
|
87
|
+
!Array.isArray(overlay)
|
|
88
|
+
) {
|
|
89
|
+
const out = { ...base };
|
|
90
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
91
|
+
out[key] = key in out ? deepMerge(out[key], value) : value;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
return overlay === undefined ? base : overlay;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeControlPanelConfig(raw = {}) {
|
|
99
|
+
const input = raw && typeof raw === "object" ? raw : {};
|
|
100
|
+
return deepMerge(DEFAULT_CONTROL_PANEL_CONFIG, input);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveControlPanelConfigPath(options = {}) {
|
|
104
|
+
const env = options.env || process.env;
|
|
105
|
+
const explicit = String(
|
|
106
|
+
options.explicitPath || env.TANDEM_CONTROL_PANEL_CONFIG_FILE || ""
|
|
107
|
+
).trim();
|
|
108
|
+
if (explicit) return resolve(explicit);
|
|
109
|
+
const stateDir = String(
|
|
110
|
+
options.stateDir || env.TANDEM_CONTROL_PANEL_STATE_DIR || ""
|
|
111
|
+
).trim();
|
|
112
|
+
const fallbackStateDir = stateDir || resolve(process.cwd(), "tandem-data", "control-panel");
|
|
113
|
+
return resolve(fallbackStateDir, "control-panel-config.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readControlPanelConfig(pathname, fallback = DEFAULT_CONTROL_PANEL_CONFIG) {
|
|
117
|
+
const target = String(pathname || "").trim();
|
|
118
|
+
if (!target || !existsSync(target)) {
|
|
119
|
+
return normalizeControlPanelConfig(fallback);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const raw = JSON.parse(readFileSync(target, "utf8"));
|
|
123
|
+
return normalizeControlPanelConfig(raw);
|
|
124
|
+
} catch {
|
|
125
|
+
return normalizeControlPanelConfig(fallback);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writeControlPanelConfig(pathname, payload) {
|
|
130
|
+
const target = resolve(String(pathname || "").trim());
|
|
131
|
+
const data = normalizeControlPanelConfig(payload);
|
|
132
|
+
await mkdir(dirname(target), { recursive: true });
|
|
133
|
+
writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
134
|
+
return { path: target, config: data };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveControlPanelMode({
|
|
138
|
+
config,
|
|
139
|
+
envMode,
|
|
140
|
+
acaAvailable,
|
|
141
|
+
} = {}) {
|
|
142
|
+
const normalizedEnvMode = String(envMode || "").trim().toLowerCase();
|
|
143
|
+
const configMode = String(config?.control_panel?.mode || "").trim().toLowerCase();
|
|
144
|
+
const explicitMode = ["aca", "standalone"].includes(normalizedEnvMode)
|
|
145
|
+
? normalizedEnvMode
|
|
146
|
+
: "";
|
|
147
|
+
const requestedMode = explicitMode || configMode;
|
|
148
|
+
if (requestedMode === "aca" || requestedMode === "standalone") {
|
|
149
|
+
return {
|
|
150
|
+
mode: requestedMode,
|
|
151
|
+
source: explicitMode ? "env" : "config",
|
|
152
|
+
reason: explicitMode ? `forced via TANDEM_CONTROL_PANEL_MODE=${requestedMode}` : "",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
mode: acaAvailable ? "aca" : "standalone",
|
|
157
|
+
source: "detected",
|
|
158
|
+
reason: acaAvailable
|
|
159
|
+
? "ACA integration detected on startup."
|
|
160
|
+
: "ACA integration not detected; using the standalone setup profile.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function summarizeControlPanelConfig(config) {
|
|
165
|
+
const normalized = normalizeControlPanelConfig(config);
|
|
166
|
+
const missing = [];
|
|
167
|
+
if (
|
|
168
|
+
!String(normalized.repository.path || "").trim() &&
|
|
169
|
+
!String(normalized.repository.slug || "").trim() &&
|
|
170
|
+
!String(normalized.repository.clone_url || "").trim()
|
|
171
|
+
) {
|
|
172
|
+
missing.push("repository");
|
|
173
|
+
}
|
|
174
|
+
if (!String(normalized.task_source.type || "").trim()) {
|
|
175
|
+
missing.push("task_source");
|
|
176
|
+
}
|
|
177
|
+
if (!String(normalized.provider.id || "").trim() || !String(normalized.provider.model || "").trim()) {
|
|
178
|
+
missing.push("provider");
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
...normalized,
|
|
182
|
+
missing,
|
|
183
|
+
ready: missing.length === 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export {
|
|
188
|
+
DEFAULT_CONTROL_PANEL_CONFIG,
|
|
189
|
+
deepMerge,
|
|
190
|
+
normalizeControlPanelConfig,
|
|
191
|
+
readControlPanelConfig,
|
|
192
|
+
resolveControlPanelConfigPath,
|
|
193
|
+
resolveControlPanelMode,
|
|
194
|
+
summarizeControlPanelConfig,
|
|
195
|
+
writeControlPanelConfig,
|
|
196
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frumu/tandem-panel",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.16",
|
|
4
4
|
"description": "Full web control center for Tandem Engine (chat, routines, swarm, memory, channels, and ops)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"docker:token": "node bin/docker-token.js",
|
|
24
24
|
"prepublishOnly": "npm run build",
|
|
25
25
|
"start": "node bin/cli.js run",
|
|
26
|
+
"agents:catalog:refresh": "node ../../scripts/generate-agent-catalog.mjs",
|
|
26
27
|
"mcp:catalog:refresh": "node ../../scripts/generate-mcp-catalog.mjs"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"lib",
|
|
31
32
|
"server",
|
|
32
33
|
"dist",
|
|
34
|
+
"src/generated",
|
|
33
35
|
".env.example",
|
|
34
36
|
"README.md"
|
|
35
37
|
],
|
|
@@ -40,8 +42,12 @@
|
|
|
40
42
|
"homepage": "https://tandem.frumu.ai",
|
|
41
43
|
"author": "Frumu Ltd",
|
|
42
44
|
"dependencies": {
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
+
"@fullcalendar/core": "6.1.20",
|
|
46
|
+
"@fullcalendar/interaction": "6.1.20",
|
|
47
|
+
"@fullcalendar/react": "6.1.20",
|
|
48
|
+
"@fullcalendar/timegrid": "6.1.20",
|
|
49
|
+
"@frumu/tandem": "^0.4.16",
|
|
50
|
+
"@frumu/tandem-client": "^0.4.16",
|
|
45
51
|
"@tanstack/react-query": "^5.90.21",
|
|
46
52
|
"dompurify": "^3.3.1",
|
|
47
53
|
"lucide": "^0.575.0",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
function copyRequestHeaders(req) {
|
|
2
|
+
const headers = new Headers();
|
|
3
|
+
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
4
|
+
if (!value) continue;
|
|
5
|
+
const lower = key.toLowerCase();
|
|
6
|
+
if (["host", "content-length", "cookie", "authorization"].includes(lower)) {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (Array.isArray(value)) headers.set(key, value.join(", "));
|
|
10
|
+
else headers.set(key, value);
|
|
11
|
+
}
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createAcaApiHandler(deps) {
|
|
16
|
+
const { PORTAL_PORT, ACA_BASE_URL, getAcaToken, sendJson } = deps;
|
|
17
|
+
|
|
18
|
+
return async function handleAcaApi(req, res) {
|
|
19
|
+
const baseUrl = String(ACA_BASE_URL || "").trim().replace(/\/+$/, "");
|
|
20
|
+
if (!baseUrl) {
|
|
21
|
+
sendJson(res, 503, {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: "ACA integration is not configured. Set ACA_BASE_URL to enable ACA-backed coding.",
|
|
24
|
+
});
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const incoming = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
|
|
29
|
+
const targetPath = incoming.pathname.replace(/^\/api\/aca/, "") || "/";
|
|
30
|
+
const targetUrl = `${baseUrl}${targetPath}${incoming.search}`;
|
|
31
|
+
const token = String(getAcaToken?.() || "aca-proxy").trim();
|
|
32
|
+
const needsAuth = targetPath !== "/health";
|
|
33
|
+
|
|
34
|
+
const headers = copyRequestHeaders(req);
|
|
35
|
+
if (needsAuth && token) headers.set("authorization", `Bearer ${token}`);
|
|
36
|
+
if (!headers.has("accept")) headers.set("accept", "*/*");
|
|
37
|
+
|
|
38
|
+
const hasBody = !["GET", "HEAD"].includes(req.method || "GET");
|
|
39
|
+
|
|
40
|
+
let upstream;
|
|
41
|
+
try {
|
|
42
|
+
upstream = await fetch(targetUrl, {
|
|
43
|
+
method: req.method,
|
|
44
|
+
headers,
|
|
45
|
+
body: hasBody ? req : undefined,
|
|
46
|
+
duplex: hasBody ? "half" : undefined,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
sendJson(res, 502, {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `ACA unreachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const responseHeaders = {};
|
|
57
|
+
upstream.headers.forEach((value, key) => {
|
|
58
|
+
const lower = key.toLowerCase();
|
|
59
|
+
if (["content-encoding", "transfer-encoding", "connection"].includes(lower)) return;
|
|
60
|
+
responseHeaders[key] = value;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
res.writeHead(upstream.status, responseHeaders);
|
|
65
|
+
if (!upstream.body) {
|
|
66
|
+
res.end();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
for await (const chunk of upstream.body) {
|
|
70
|
+
if (res.writableEnded || res.destroyed) break;
|
|
71
|
+
res.write(chunk);
|
|
72
|
+
}
|
|
73
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
74
|
+
res.end();
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
if (res.headersSent) {
|
|
79
|
+
const lower = message.toLowerCase();
|
|
80
|
+
if (lower.includes("terminated") || lower.includes("aborted")) {
|
|
81
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
85
|
+
res.destroy(error instanceof Error ? error : undefined);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
sendJson(res, 502, {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `ACA proxy stream failed: ${message}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const DEFAULT_CAPABILITY_CACHE_TTL_MS = 45_000;
|
|
2
|
+
|
|
3
|
+
let _cache = {
|
|
4
|
+
value: null,
|
|
5
|
+
expiresAt: 0,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
let _lastReported = {
|
|
9
|
+
aca_available: null,
|
|
10
|
+
engine_healthy: null,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const _metrics = {
|
|
14
|
+
detect_duration_ms: 0,
|
|
15
|
+
detect_ok: false,
|
|
16
|
+
last_detect_at_ms: 0,
|
|
17
|
+
aca_probe_error_counts: {
|
|
18
|
+
aca_not_configured: 0,
|
|
19
|
+
aca_endpoint_not_found: 0,
|
|
20
|
+
aca_probe_timeout: 0,
|
|
21
|
+
aca_probe_error: 0,
|
|
22
|
+
aca_health_failed_xxx: 0,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function logCapabilityTransition(next) {
|
|
27
|
+
const prev = _lastReported;
|
|
28
|
+
const ts = new Date().toISOString();
|
|
29
|
+
if (prev.aca_available !== next.aca_integration) {
|
|
30
|
+
console.log(
|
|
31
|
+
`[Capabilities] ${ts} ACA integration: ${prev.aca_available ?? "unknown"} → ${next.aca_integration} (reason: ${next.aca_reason || "n/a"})`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (prev.engine_healthy !== next.engine_healthy) {
|
|
35
|
+
console.log(
|
|
36
|
+
`[Capabilities] ${ts} Engine healthy: ${prev.engine_healthy ?? "unknown"} → ${next.engine_healthy}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
_lastReported = { aca_available: next.aca_integration, engine_healthy: next.engine_healthy };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function incrementProbeError(reason) {
|
|
43
|
+
const bucket =
|
|
44
|
+
reason in _metrics.aca_probe_error_counts
|
|
45
|
+
? reason
|
|
46
|
+
: reason.match(/^aca_health_failed_\d+$/)
|
|
47
|
+
? "aca_health_failed_xxx"
|
|
48
|
+
: null;
|
|
49
|
+
if (bucket) {
|
|
50
|
+
_metrics.aca_probe_error_counts[bucket] += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createCapabilitiesHandler(deps) {
|
|
55
|
+
const {
|
|
56
|
+
PROBE_TIMEOUT_MS = 5_000,
|
|
57
|
+
ACA_BASE_URL,
|
|
58
|
+
ACA_HEALTH_PATH = "/health",
|
|
59
|
+
getAcaToken,
|
|
60
|
+
getInstallProfile,
|
|
61
|
+
engineHealth,
|
|
62
|
+
cacheTtlMs = DEFAULT_CAPABILITY_CACHE_TTL_MS,
|
|
63
|
+
} = deps;
|
|
64
|
+
|
|
65
|
+
async function probeAca() {
|
|
66
|
+
const base = String(ACA_BASE_URL || "").trim();
|
|
67
|
+
if (!base) {
|
|
68
|
+
incrementProbeError("aca_not_configured");
|
|
69
|
+
return { available: false, reason: "aca_not_configured" };
|
|
70
|
+
}
|
|
71
|
+
const target = `${base.replace(/\/+$/, "")}${ACA_HEALTH_PATH}`;
|
|
72
|
+
const token = String(getAcaToken?.() || "").trim();
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(target, {
|
|
77
|
+
method: "GET",
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
headers: {
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
if (res.ok) return { available: true, reason: "" };
|
|
86
|
+
if (res.status === 404 || res.status === 405) {
|
|
87
|
+
incrementProbeError("aca_endpoint_not_found");
|
|
88
|
+
return { available: false, reason: "aca_endpoint_not_found" };
|
|
89
|
+
}
|
|
90
|
+
incrementProbeError(`aca_health_failed_${res.status}`);
|
|
91
|
+
return { available: false, reason: `aca_health_failed_${res.status}` };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
const msg = String(err?.message || "");
|
|
95
|
+
if (msg.includes("abort")) {
|
|
96
|
+
incrementProbeError("aca_probe_timeout");
|
|
97
|
+
return { available: false, reason: "aca_probe_timeout" };
|
|
98
|
+
}
|
|
99
|
+
incrementProbeError("aca_probe_error");
|
|
100
|
+
return { available: false, reason: "aca_probe_error" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function probeEngineFeatures(engineOk, acaOk) {
|
|
105
|
+
if (!engineOk && !acaOk) {
|
|
106
|
+
return { coding_workflows: false, missions: false, agent_teams: false, coder: false };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
coding_workflows: engineOk || acaOk,
|
|
110
|
+
missions: true,
|
|
111
|
+
agent_teams: true,
|
|
112
|
+
coder: engineOk,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function engineIsHealthy(health) {
|
|
117
|
+
const engine = health?.engine && typeof health.engine === "object" ? health.engine : health;
|
|
118
|
+
return !!(engine?.ready || engine?.healthy);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return async function handleCapabilities(req, res) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (_cache.value && now < _cache.expiresAt) {
|
|
124
|
+
deps.sendJson(res, 200, _cache.value);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const t0 = Date.now();
|
|
129
|
+
const health = await engineHealth().catch(() => null);
|
|
130
|
+
const engineOk = engineIsHealthy(health);
|
|
131
|
+
const aca = await probeAca();
|
|
132
|
+
const features = await probeEngineFeatures(engineOk, aca.available);
|
|
133
|
+
const durationMs = Date.now() - t0;
|
|
134
|
+
let installProfile = null;
|
|
135
|
+
if (typeof getInstallProfile === "function") {
|
|
136
|
+
try {
|
|
137
|
+
installProfile = await getInstallProfile({
|
|
138
|
+
acaAvailable: aca.available,
|
|
139
|
+
engineHealthy: engineOk,
|
|
140
|
+
acaReason: aca.reason,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
installProfile = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_metrics.detect_duration_ms = durationMs;
|
|
148
|
+
_metrics.detect_ok = true;
|
|
149
|
+
_metrics.last_detect_at_ms = now;
|
|
150
|
+
|
|
151
|
+
const result = {
|
|
152
|
+
aca_integration: aca.available,
|
|
153
|
+
aca_reason: aca.reason,
|
|
154
|
+
coding_workflows: features.coding_workflows,
|
|
155
|
+
missions: features.missions,
|
|
156
|
+
agent_teams: features.agent_teams,
|
|
157
|
+
coder: features.coder,
|
|
158
|
+
engine_healthy: engineOk,
|
|
159
|
+
cached_at_ms: now,
|
|
160
|
+
control_panel_mode: installProfile?.control_panel_mode || (aca.available ? "aca" : "standalone"),
|
|
161
|
+
control_panel_mode_source: installProfile?.control_panel_mode_source || "detected",
|
|
162
|
+
control_panel_mode_reason: installProfile?.control_panel_mode_reason || "",
|
|
163
|
+
control_panel_config_path: installProfile?.control_panel_config_path || "",
|
|
164
|
+
control_panel_config_ready: !!installProfile?.control_panel_config_ready,
|
|
165
|
+
control_panel_config_missing: Array.isArray(installProfile?.control_panel_config_missing)
|
|
166
|
+
? installProfile.control_panel_config_missing
|
|
167
|
+
: [],
|
|
168
|
+
control_panel_compact_nav: !!installProfile?.control_panel_compact_nav,
|
|
169
|
+
_internal: {
|
|
170
|
+
capability_detect_duration_ms: durationMs,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
logCapabilityTransition(result);
|
|
175
|
+
|
|
176
|
+
_cache.value = result;
|
|
177
|
+
_cache.expiresAt = now + cacheTtlMs;
|
|
178
|
+
|
|
179
|
+
deps.sendJson(res, 200, result);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function getCapabilitiesMetrics() {
|
|
184
|
+
return {
|
|
185
|
+
..._metrics,
|
|
186
|
+
aca_probe_error_counts: { ..._metrics.aca_probe_error_counts },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resetCapabilitiesCache() {
|
|
191
|
+
_cache.value = null;
|
|
192
|
+
_cache.expiresAt = 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function resetCapabilitiesState() {
|
|
196
|
+
_lastReported.aca_available = null;
|
|
197
|
+
_lastReported.engine_healthy = null;
|
|
198
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readControlPanelConfig,
|
|
3
|
+
resolveControlPanelConfigPath,
|
|
4
|
+
resolveControlPanelMode,
|
|
5
|
+
summarizeControlPanelConfig,
|
|
6
|
+
writeControlPanelConfig,
|
|
7
|
+
} from "../../lib/setup/control-panel-config.js";
|
|
8
|
+
|
|
9
|
+
export function createControlPanelConfigHandler(deps) {
|
|
10
|
+
const { CONTROL_PANEL_CONFIG_FILE, CONTROL_PANEL_MODE, ACA_BASE_URL, getAcaToken, sendJson } =
|
|
11
|
+
deps;
|
|
12
|
+
|
|
13
|
+
function getConfigPath() {
|
|
14
|
+
return resolveControlPanelConfigPath({
|
|
15
|
+
env: {
|
|
16
|
+
TANDEM_CONTROL_PANEL_CONFIG_FILE: CONTROL_PANEL_CONFIG_FILE,
|
|
17
|
+
TANDEM_CONTROL_PANEL_STATE_DIR: deps.TANDEM_CONTROL_PANEL_STATE_DIR,
|
|
18
|
+
},
|
|
19
|
+
explicitPath: CONTROL_PANEL_CONFIG_FILE,
|
|
20
|
+
stateDir: deps.TANDEM_CONTROL_PANEL_STATE_DIR,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadInstallProfile() {
|
|
25
|
+
const configPath = getConfigPath();
|
|
26
|
+
const config = readControlPanelConfig(configPath);
|
|
27
|
+
const baseUrl = String(ACA_BASE_URL || "").trim();
|
|
28
|
+
const token = String(getAcaToken?.() || "").trim();
|
|
29
|
+
let acaAvailable = false;
|
|
30
|
+
let acaReason = "aca_not_configured";
|
|
31
|
+
if (baseUrl) {
|
|
32
|
+
try {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), Number(deps.PROBE_TIMEOUT_MS || 5000));
|
|
35
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
|
|
36
|
+
method: "GET",
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
headers: {
|
|
39
|
+
Accept: "application/json",
|
|
40
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
acaAvailable = !!res.ok;
|
|
45
|
+
acaReason = res.ok ? "" : `aca_health_failed_${res.status}`;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
acaReason = String(error?.message || error || "aca_probe_error");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const mode = resolveControlPanelMode({
|
|
51
|
+
config,
|
|
52
|
+
envMode: CONTROL_PANEL_MODE,
|
|
53
|
+
acaAvailable,
|
|
54
|
+
});
|
|
55
|
+
const summary = summarizeControlPanelConfig(config);
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
control_panel_mode: mode.mode,
|
|
59
|
+
control_panel_mode_source: mode.source,
|
|
60
|
+
control_panel_mode_reason: mode.reason || "",
|
|
61
|
+
aca_integration: acaAvailable,
|
|
62
|
+
aca_reason: acaReason,
|
|
63
|
+
control_panel_config_path: configPath,
|
|
64
|
+
control_panel_config_ready: summary.ready,
|
|
65
|
+
control_panel_config_missing: summary.missing,
|
|
66
|
+
control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
|
|
67
|
+
config: summary,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return async function handleControlPanelConfig(req, res) {
|
|
72
|
+
const incoming = new URL(req.url, "http://127.0.0.1");
|
|
73
|
+
if (incoming.pathname === "/api/install/profile" && req.method === "GET") {
|
|
74
|
+
const payload = await loadInstallProfile();
|
|
75
|
+
sendJson(res, 200, payload);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (incoming.pathname === "/api/control-panel/config" && req.method === "GET") {
|
|
80
|
+
const configPath = getConfigPath();
|
|
81
|
+
const config = readControlPanelConfig(configPath);
|
|
82
|
+
sendJson(res, 200, {
|
|
83
|
+
ok: true,
|
|
84
|
+
path: configPath,
|
|
85
|
+
config,
|
|
86
|
+
summary: summarizeControlPanelConfig(config),
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (incoming.pathname === "/api/control-panel/config" && req.method === "PATCH") {
|
|
92
|
+
const configPath = getConfigPath();
|
|
93
|
+
const payload = await deps.readJsonBody(req);
|
|
94
|
+
const saved = await writeControlPanelConfig(configPath, payload?.config || payload);
|
|
95
|
+
sendJson(res, 200, {
|
|
96
|
+
ok: true,
|
|
97
|
+
path: saved.path,
|
|
98
|
+
config: saved.config,
|
|
99
|
+
summary: summarizeControlPanelConfig(saved.config),
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
106
|
+
}
|