@frumu/tandem-panel 0.4.15 → 0.4.17
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 +10 -3
- package/README.md +27 -8
- package/bin/cli.js +3 -1
- package/bin/setup.js +323 -13
- package/dist/assets/index-BsS1Z15-.css +1 -0
- package/dist/assets/index-DtaAHVxs.js +2495 -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-BiFBqyAt.js} +1 -1
- package/dist/assets/vendor-Q0KoFXrG.js +180 -0
- package/dist/index.html +6 -6
- package/lib/setup/control-panel-config.js +196 -0
- package/lib/setup/env.js +1 -1
- package/package.json +10 -4
- package/server/routes/aca.js +97 -0
- package/server/routes/capabilities.js +51 -12
- package/server/routes/control-panel-config.js +106 -0
- package/src/generated/agent-catalog.json +2254 -0
- package/dist/assets/index-DAtDe1Vc.js +0 -2460
- package/dist/assets/index-DzX1-UXX.css +0 -1
- 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-DtaAHVxs.js"></script>
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/preact-vendor-CWXGD9A4.js">
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-Q0KoFXrG.js">
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/assets/react-query-BiFBqyAt.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-BsS1Z15-.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/lib/setup/env.js
CHANGED
|
@@ -70,7 +70,7 @@ function bootstrapDefaults(paths) {
|
|
|
70
70
|
TANDEM_DISABLE_TOOL_GUARD_BUDGETS: "1",
|
|
71
71
|
TANDEM_TOOL_ROUTER_ENABLED: "0",
|
|
72
72
|
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS: "5000",
|
|
73
|
-
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS: "
|
|
73
|
+
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS: "90000",
|
|
74
74
|
TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS: "90000",
|
|
75
75
|
TANDEM_PERMISSION_WAIT_TIMEOUT_MS: "15000",
|
|
76
76
|
TANDEM_TOOL_EXEC_TIMEOUT_MS: "45000",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frumu/tandem-panel",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
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
|
],
|
|
@@ -37,11 +39,15 @@
|
|
|
37
39
|
"type": "git",
|
|
38
40
|
"url": "git+https://github.com/frumu-ai/tandem.git"
|
|
39
41
|
},
|
|
40
|
-
"homepage": "https://tandem.
|
|
42
|
+
"homepage": "https://tandem.ac",
|
|
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.17",
|
|
50
|
+
"@frumu/tandem-client": "^0.4.17",
|
|
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
|
+
}
|
|
@@ -27,18 +27,25 @@ function logCapabilityTransition(next) {
|
|
|
27
27
|
const prev = _lastReported;
|
|
28
28
|
const ts = new Date().toISOString();
|
|
29
29
|
if (prev.aca_available !== next.aca_integration) {
|
|
30
|
-
console.log(
|
|
30
|
+
console.log(
|
|
31
|
+
`[Capabilities] ${ts} ACA integration: ${prev.aca_available ?? "unknown"} → ${next.aca_integration} (reason: ${next.aca_reason || "n/a"})`
|
|
32
|
+
);
|
|
31
33
|
}
|
|
32
34
|
if (prev.engine_healthy !== next.engine_healthy) {
|
|
33
|
-
console.log(
|
|
35
|
+
console.log(
|
|
36
|
+
`[Capabilities] ${ts} Engine healthy: ${prev.engine_healthy ?? "unknown"} → ${next.engine_healthy}`
|
|
37
|
+
);
|
|
34
38
|
}
|
|
35
39
|
_lastReported = { aca_available: next.aca_integration, engine_healthy: next.engine_healthy };
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function incrementProbeError(reason) {
|
|
39
|
-
const bucket =
|
|
40
|
-
|
|
41
|
-
|
|
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;
|
|
42
49
|
if (bucket) {
|
|
43
50
|
_metrics.aca_probe_error_counts[bucket] += 1;
|
|
44
51
|
}
|
|
@@ -49,6 +56,8 @@ export function createCapabilitiesHandler(deps) {
|
|
|
49
56
|
PROBE_TIMEOUT_MS = 5_000,
|
|
50
57
|
ACA_BASE_URL,
|
|
51
58
|
ACA_HEALTH_PATH = "/health",
|
|
59
|
+
getAcaToken,
|
|
60
|
+
getInstallProfile,
|
|
52
61
|
engineHealth,
|
|
53
62
|
cacheTtlMs = DEFAULT_CAPABILITY_CACHE_TTL_MS,
|
|
54
63
|
} = deps;
|
|
@@ -60,13 +69,17 @@ export function createCapabilitiesHandler(deps) {
|
|
|
60
69
|
return { available: false, reason: "aca_not_configured" };
|
|
61
70
|
}
|
|
62
71
|
const target = `${base.replace(/\/+$/, "")}${ACA_HEALTH_PATH}`;
|
|
72
|
+
const token = String(getAcaToken?.() || "").trim();
|
|
63
73
|
const controller = new AbortController();
|
|
64
74
|
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
65
75
|
try {
|
|
66
76
|
const res = await fetch(target, {
|
|
67
77
|
method: "GET",
|
|
68
78
|
signal: controller.signal,
|
|
69
|
-
headers: {
|
|
79
|
+
headers: {
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
82
|
+
},
|
|
70
83
|
});
|
|
71
84
|
clearTimeout(timer);
|
|
72
85
|
if (res.ok) return { available: true, reason: "" };
|
|
@@ -88,18 +101,23 @@ export function createCapabilitiesHandler(deps) {
|
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
async function probeEngineFeatures(engineOk) {
|
|
92
|
-
if (!engineOk) {
|
|
104
|
+
async function probeEngineFeatures(engineOk, acaOk) {
|
|
105
|
+
if (!engineOk && !acaOk) {
|
|
93
106
|
return { coding_workflows: false, missions: false, agent_teams: false, coder: false };
|
|
94
107
|
}
|
|
95
108
|
return {
|
|
96
|
-
coding_workflows:
|
|
109
|
+
coding_workflows: engineOk || acaOk,
|
|
97
110
|
missions: true,
|
|
98
111
|
agent_teams: true,
|
|
99
|
-
coder:
|
|
112
|
+
coder: engineOk,
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
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
|
+
|
|
103
121
|
return async function handleCapabilities(req, res) {
|
|
104
122
|
const now = Date.now();
|
|
105
123
|
if (_cache.value && now < _cache.expiresAt) {
|
|
@@ -109,10 +127,22 @@ export function createCapabilitiesHandler(deps) {
|
|
|
109
127
|
|
|
110
128
|
const t0 = Date.now();
|
|
111
129
|
const health = await engineHealth().catch(() => null);
|
|
112
|
-
const engineOk =
|
|
130
|
+
const engineOk = engineIsHealthy(health);
|
|
113
131
|
const aca = await probeAca();
|
|
114
|
-
const features = await probeEngineFeatures(engineOk);
|
|
132
|
+
const features = await probeEngineFeatures(engineOk, aca.available);
|
|
115
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
|
+
}
|
|
116
146
|
|
|
117
147
|
_metrics.detect_duration_ms = durationMs;
|
|
118
148
|
_metrics.detect_ok = true;
|
|
@@ -127,6 +157,15 @@ export function createCapabilitiesHandler(deps) {
|
|
|
127
157
|
coder: features.coder,
|
|
128
158
|
engine_healthy: engineOk,
|
|
129
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,
|
|
130
169
|
_internal: {
|
|
131
170
|
capability_detect_duration_ms: durationMs,
|
|
132
171
|
},
|
|
@@ -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
|
+
}
|