pilotswarm-cli 0.1.14 → 0.1.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/README.md +3 -0
- package/bin/tui.js +8 -2
- package/node_modules/pilotswarm-ui-core/README.md +6 -0
- package/node_modules/pilotswarm-ui-core/package.json +32 -0
- package/node_modules/pilotswarm-ui-core/src/commands.js +72 -0
- package/node_modules/pilotswarm-ui-core/src/context-usage.js +212 -0
- package/node_modules/pilotswarm-ui-core/src/controller.js +3676 -0
- package/node_modules/pilotswarm-ui-core/src/formatting.js +872 -0
- package/node_modules/pilotswarm-ui-core/src/history.js +589 -0
- package/node_modules/pilotswarm-ui-core/src/index.js +13 -0
- package/node_modules/pilotswarm-ui-core/src/layout.js +196 -0
- package/node_modules/pilotswarm-ui-core/src/reducer.js +1030 -0
- package/node_modules/pilotswarm-ui-core/src/selectors.js +2921 -0
- package/node_modules/pilotswarm-ui-core/src/session-tree.js +109 -0
- package/node_modules/pilotswarm-ui-core/src/state.js +80 -0
- package/node_modules/pilotswarm-ui-core/src/store.js +23 -0
- package/node_modules/pilotswarm-ui-core/src/system-titles.js +24 -0
- package/node_modules/pilotswarm-ui-core/src/themes/catppuccin-mocha.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/cobalt2.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dark-high-contrast.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/dracula.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/github-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/gruvbox-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-matrix.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-orion-prime.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/helpers.js +77 -0
- package/node_modules/pilotswarm-ui-core/src/themes/index.js +44 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis-obscuro.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis-viola.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/noctis.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/nord.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/solarized-dark.js +56 -0
- package/node_modules/pilotswarm-ui-core/src/themes/tokyo-night.js +56 -0
- package/node_modules/pilotswarm-ui-react/README.md +5 -0
- package/node_modules/pilotswarm-ui-react/package.json +36 -0
- package/node_modules/pilotswarm-ui-react/src/components.js +1413 -0
- package/node_modules/pilotswarm-ui-react/src/index.js +4 -0
- package/node_modules/pilotswarm-ui-react/src/platform.js +15 -0
- package/node_modules/pilotswarm-ui-react/src/use-controller-state.js +38 -0
- package/node_modules/pilotswarm-ui-react/src/web-app.js +2759 -0
- package/package.json +9 -3
- package/src/app.js +44 -0
- package/src/bootstrap-env.js +1 -37
- package/src/node-sdk-transport.js +555 -62
- package/src/platform.js +43 -11
- package/src/plugin-config.js +239 -0
- package/src/portal.js +7 -0
- package/src/sync-workspace-ui.js +53 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import https from "node:https";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import {
|
|
@@ -10,9 +11,67 @@ import {
|
|
|
10
11
|
SessionBlobStore,
|
|
11
12
|
} from "pilotswarm-sdk";
|
|
12
13
|
import { startEmbeddedWorkers, stopEmbeddedWorkers } from "./embedded-workers.js";
|
|
14
|
+
import { getPluginDirsFromEnv } from "./plugin-config.js";
|
|
13
15
|
|
|
14
|
-
const EXPORTS_DIR = path.
|
|
16
|
+
const EXPORTS_DIR = path.resolve(
|
|
17
|
+
expandUserPath(process.env.PILOTSWARM_EXPORT_DIR || path.join(os.homedir(), "pilotswarm-exports")),
|
|
18
|
+
);
|
|
15
19
|
fs.mkdirSync(EXPORTS_DIR, { recursive: true });
|
|
20
|
+
const K8S_SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
21
|
+
|
|
22
|
+
function fileExists(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.existsSync(filePath);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getInClusterK8sPaths() {
|
|
31
|
+
const baseDir = process.env.PILOTSWARM_K8S_SERVICE_ACCOUNT_DIR || K8S_SERVICE_ACCOUNT_DIR;
|
|
32
|
+
return {
|
|
33
|
+
tokenPath: process.env.PILOTSWARM_K8S_TOKEN_PATH || path.join(baseDir, "token"),
|
|
34
|
+
caPath: process.env.PILOTSWARM_K8S_CA_PATH || path.join(baseDir, "ca.crt"),
|
|
35
|
+
namespacePath: process.env.PILOTSWARM_K8S_NAMESPACE_PATH || path.join(baseDir, "namespace"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readOptionalTextFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasInClusterK8sAccess() {
|
|
48
|
+
const { tokenPath, caPath } = getInClusterK8sPaths();
|
|
49
|
+
return Boolean(process.env.KUBERNETES_SERVICE_HOST)
|
|
50
|
+
&& fileExists(tokenPath)
|
|
51
|
+
&& fileExists(caPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getInClusterK8sConfig() {
|
|
55
|
+
if (!hasInClusterK8sAccess()) return null;
|
|
56
|
+
|
|
57
|
+
const { tokenPath, caPath, namespacePath } = getInClusterK8sPaths();
|
|
58
|
+
return {
|
|
59
|
+
host: String(process.env.KUBERNETES_SERVICE_HOST || "").trim(),
|
|
60
|
+
port: Number(process.env.KUBERNETES_SERVICE_PORT || 443) || 443,
|
|
61
|
+
token: readOptionalTextFile(tokenPath),
|
|
62
|
+
ca: fs.readFileSync(caPath),
|
|
63
|
+
namespace: String(process.env.K8S_NAMESPACE || "").trim() || readOptionalTextFile(namespacePath) || "default",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasExplicitKubectlConfig() {
|
|
68
|
+
return Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isKubectlAvailable() {
|
|
72
|
+
const result = spawnSync("kubectl", ["version", "--client=true"], { stdio: "ignore" });
|
|
73
|
+
return !result.error;
|
|
74
|
+
}
|
|
16
75
|
|
|
17
76
|
function stripAnsi(value) {
|
|
18
77
|
return String(value || "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -105,9 +164,14 @@ function buildLogEntry(line, counter) {
|
|
|
105
164
|
const prefixMatch = line.match(/^\[pod\/([^/\]]+)/);
|
|
106
165
|
const podName = prefixMatch ? prefixMatch[1] : "unknown";
|
|
107
166
|
const rawLine = trimLogText(stripAnsi(line.replace(/^\[pod\/[^\]]+\]\s*/, "")).trim());
|
|
108
|
-
const orchMatch = rawLine.match(/\b(instance_id|orchestration_id)=(session-[^\s,]+)/i)
|
|
167
|
+
const orchMatch = rawLine.match(/\b(?:instance_id|orchestration_id|orch)=(session-[^\s,]+)/i)
|
|
109
168
|
|| rawLine.match(/\b(session-[0-9a-f-]{8,})\b/i);
|
|
110
|
-
const
|
|
169
|
+
const parsedOrchId = orchMatch ? orchMatch[1] : null;
|
|
170
|
+
const sessionIdMatch = rawLine.match(/\b(?:sessionId|session|durableSessionId)=([0-9a-f-]{8,})\b/i);
|
|
171
|
+
const sessionId = sessionIdMatch
|
|
172
|
+
? sessionIdMatch[1]
|
|
173
|
+
: (parsedOrchId && parsedOrchId.startsWith("session-") ? parsedOrchId.slice("session-".length) : null);
|
|
174
|
+
const orchId = parsedOrchId || (sessionId ? `session-${sessionId}` : null);
|
|
111
175
|
const category = rawLine.includes("duroxide::activity")
|
|
112
176
|
? "activity"
|
|
113
177
|
: rawLine.includes("duroxide::orchestration") || rawLine.includes("::orchestration")
|
|
@@ -120,6 +184,7 @@ function buildLogEntry(line, counter) {
|
|
|
120
184
|
podName,
|
|
121
185
|
level: normalizeLogLevel(rawLine),
|
|
122
186
|
orchId,
|
|
187
|
+
sessionId,
|
|
123
188
|
category,
|
|
124
189
|
rawLine,
|
|
125
190
|
message: extractPrettyLogMessage(rawLine),
|
|
@@ -127,6 +192,22 @@ function buildLogEntry(line, counter) {
|
|
|
127
192
|
};
|
|
128
193
|
}
|
|
129
194
|
|
|
195
|
+
function buildSyntheticLogEntry({ message, level = "info", podName = "k8s", counter = 0 }) {
|
|
196
|
+
const safeMessage = trimLogText(String(message || "").trim());
|
|
197
|
+
return {
|
|
198
|
+
id: `log:${Date.now()}:${counter}`,
|
|
199
|
+
time: extractLogTime(safeMessage),
|
|
200
|
+
podName,
|
|
201
|
+
level,
|
|
202
|
+
orchId: null,
|
|
203
|
+
sessionId: null,
|
|
204
|
+
category: "log",
|
|
205
|
+
rawLine: safeMessage,
|
|
206
|
+
message: safeMessage,
|
|
207
|
+
prettyMessage: safeMessage,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
130
211
|
function sanitizeArtifactFilename(filename) {
|
|
131
212
|
return String(filename || "").replace(/[/\\]/g, "_");
|
|
132
213
|
}
|
|
@@ -139,6 +220,73 @@ function expandUserPath(filePath) {
|
|
|
139
220
|
: value;
|
|
140
221
|
}
|
|
141
222
|
|
|
223
|
+
function getLocalLogDir() {
|
|
224
|
+
const configured = expandUserPath(process.env.PILOTSWARM_LOG_DIR || "");
|
|
225
|
+
return configured ? path.resolve(configured) : "";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function listLocalLogFiles(logDir) {
|
|
229
|
+
if (!logDir || !fileExists(logDir)) return [];
|
|
230
|
+
try {
|
|
231
|
+
return fs.readdirSync(logDir, { withFileTypes: true })
|
|
232
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".log"))
|
|
233
|
+
.map((entry) => path.join(logDir, entry.name))
|
|
234
|
+
.sort();
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readRecentLogLines(filePath, maxBytes = 128 * 1024, maxLines = 200) {
|
|
241
|
+
try {
|
|
242
|
+
const stats = fs.statSync(filePath);
|
|
243
|
+
if (!stats.isFile() || stats.size <= 0) return [];
|
|
244
|
+
const fd = fs.openSync(filePath, "r");
|
|
245
|
+
try {
|
|
246
|
+
const bytesToRead = Math.min(stats.size, maxBytes);
|
|
247
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
248
|
+
fs.readSync(fd, buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
249
|
+
let text = buffer.toString("utf8");
|
|
250
|
+
if (bytesToRead < stats.size) {
|
|
251
|
+
const newlineIndex = text.indexOf("\n");
|
|
252
|
+
text = newlineIndex >= 0 ? text.slice(newlineIndex + 1) : "";
|
|
253
|
+
}
|
|
254
|
+
return text
|
|
255
|
+
.split(/\r?\n/u)
|
|
256
|
+
.map((line) => line.trimEnd())
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.slice(-maxLines);
|
|
259
|
+
} finally {
|
|
260
|
+
fs.closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readLogChunk(filePath, start, end) {
|
|
268
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return "";
|
|
269
|
+
try {
|
|
270
|
+
const fd = fs.openSync(filePath, "r");
|
|
271
|
+
try {
|
|
272
|
+
const length = end - start;
|
|
273
|
+
const buffer = Buffer.alloc(length);
|
|
274
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
|
|
275
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
276
|
+
} finally {
|
|
277
|
+
fs.closeSync(fd);
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getLocalLogPollIntervalMs() {
|
|
285
|
+
const value = Number.parseInt(process.env.PILOTSWARM_LOG_POLL_INTERVAL_MS || "", 10);
|
|
286
|
+
if (Number.isFinite(value) && value >= 50) return value;
|
|
287
|
+
return 500;
|
|
288
|
+
}
|
|
289
|
+
|
|
142
290
|
function guessArtifactContentType(filename) {
|
|
143
291
|
const ext = path.extname(String(filename || "")).toLowerCase();
|
|
144
292
|
if (ext === ".md" || ext === ".markdown" || ext === ".mdx") return "text/markdown";
|
|
@@ -179,13 +327,6 @@ function isTerminalSendError(error) {
|
|
|
179
327
|
return /instance is terminal|terminal orchestration|cannot accept new messages/i.test(message);
|
|
180
328
|
}
|
|
181
329
|
|
|
182
|
-
function getPluginDirsFromEnv() {
|
|
183
|
-
return String(process.env.PLUGIN_DIRS || "")
|
|
184
|
-
.split(",")
|
|
185
|
-
.map((value) => value.trim())
|
|
186
|
-
.filter(Boolean);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
330
|
function normalizeCreatableAgent(agent) {
|
|
190
331
|
const name = String(agent?.name || "").trim();
|
|
191
332
|
if (!name) return null;
|
|
@@ -235,8 +376,8 @@ function loadSessionCreationMetadataFromPluginDirs(pluginDirs = []) {
|
|
|
235
376
|
}
|
|
236
377
|
|
|
237
378
|
function buildTerminalSendError(sessionId, session) {
|
|
238
|
-
if (session?.status === "failed" || session?.orchestrationStatus === "Failed") {
|
|
239
|
-
return `Session ${sessionId.slice(0, 8)} is a
|
|
379
|
+
if (session?.status === "failed" || session?.status === "cancelled" || session?.orchestrationStatus === "Failed") {
|
|
380
|
+
return `Session ${sessionId.slice(0, 8)} is a terminal orchestration and cannot accept new messages.`;
|
|
240
381
|
}
|
|
241
382
|
|
|
242
383
|
const statusLabel = String(session?.orchestrationStatus || session?.status || "Unknown");
|
|
@@ -256,10 +397,12 @@ export class NodeSdkTransport {
|
|
|
256
397
|
this.allowedAgentNames = [];
|
|
257
398
|
this.creatableAgents = [];
|
|
258
399
|
this.logProc = null;
|
|
400
|
+
this.logTailHandle = null;
|
|
259
401
|
this.logBuffer = "";
|
|
260
402
|
this.logRestartTimer = null;
|
|
261
403
|
this.logSubscribers = new Set();
|
|
262
404
|
this.logEntryCounter = 0;
|
|
405
|
+
this.kubectlAvailable = null;
|
|
263
406
|
}
|
|
264
407
|
|
|
265
408
|
async start() {
|
|
@@ -314,12 +457,41 @@ export class NodeSdkTransport {
|
|
|
314
457
|
}
|
|
315
458
|
|
|
316
459
|
getLogConfig() {
|
|
317
|
-
const
|
|
460
|
+
const localLogDir = getLocalLogDir();
|
|
461
|
+
if (localLogDir) {
|
|
462
|
+
const exists = fileExists(localLogDir);
|
|
463
|
+
return {
|
|
464
|
+
available: exists,
|
|
465
|
+
availabilityReason: exists
|
|
466
|
+
? ""
|
|
467
|
+
: `Log tailing disabled: local log directory ${JSON.stringify(localLogDir)} does not exist.`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const hasInClusterConfig = hasInClusterK8sAccess();
|
|
472
|
+
const hasKubectlConfig = hasExplicitKubectlConfig();
|
|
473
|
+
if (hasInClusterConfig) {
|
|
474
|
+
return {
|
|
475
|
+
available: true,
|
|
476
|
+
availabilityReason: "",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (hasKubectlConfig) {
|
|
481
|
+
if (this.kubectlAvailable == null) {
|
|
482
|
+
this.kubectlAvailable = isKubectlAvailable();
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
available: this.kubectlAvailable,
|
|
486
|
+
availabilityReason: this.kubectlAvailable
|
|
487
|
+
? ""
|
|
488
|
+
: "Log tailing disabled: kubectl is not installed in this environment.",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
318
492
|
return {
|
|
319
|
-
available:
|
|
320
|
-
availabilityReason:
|
|
321
|
-
? ""
|
|
322
|
-
: "Log tailing disabled: no K8S_CONTEXT configured in the env file.",
|
|
493
|
+
available: false,
|
|
494
|
+
availabilityReason: "Log tailing disabled: no K8S_CONTEXT/KUBECONFIG or in-cluster Kubernetes access detected.",
|
|
323
495
|
};
|
|
324
496
|
}
|
|
325
497
|
|
|
@@ -375,11 +547,11 @@ export class NodeSdkTransport {
|
|
|
375
547
|
if (!session) {
|
|
376
548
|
throw new Error(`Session ${sessionId.slice(0, 8)} was not found.`);
|
|
377
549
|
}
|
|
378
|
-
if (session.status === "failed" || session.orchestrationStatus === "Failed") {
|
|
550
|
+
if (session.status === "failed" || session.status === "cancelled" || session.orchestrationStatus === "Failed") {
|
|
379
551
|
throw new Error(buildTerminalSendError(sessionId, session));
|
|
380
552
|
}
|
|
381
553
|
if (
|
|
382
|
-
session.status === "completed"
|
|
554
|
+
(session.status === "completed" || session.status === "cancelled")
|
|
383
555
|
&& session.parentSessionId
|
|
384
556
|
&& !session.isSystem
|
|
385
557
|
&& !session.cronActive
|
|
@@ -480,6 +652,36 @@ export class NodeSdkTransport {
|
|
|
480
652
|
};
|
|
481
653
|
}
|
|
482
654
|
|
|
655
|
+
async uploadArtifactContent(sessionId, filename, content, contentType = guessArtifactContentType(filename)) {
|
|
656
|
+
if (!this.artifactStore) {
|
|
657
|
+
throw new Error("Artifact store is not available for this transport.");
|
|
658
|
+
}
|
|
659
|
+
const safeSessionId = String(sessionId || "").trim();
|
|
660
|
+
const safeFilename = path.basename(String(filename || "").trim());
|
|
661
|
+
const safeContent = typeof content === "string" ? content : String(content || "");
|
|
662
|
+
if (!safeSessionId) {
|
|
663
|
+
throw new Error("Session id is required for artifact upload.");
|
|
664
|
+
}
|
|
665
|
+
if (!safeFilename) {
|
|
666
|
+
throw new Error("Filename is required for artifact upload.");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
await this.artifactStore.uploadArtifact(
|
|
670
|
+
safeSessionId,
|
|
671
|
+
safeFilename,
|
|
672
|
+
safeContent,
|
|
673
|
+
contentType || guessArtifactContentType(safeFilename),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
sessionId: safeSessionId,
|
|
678
|
+
filename: safeFilename,
|
|
679
|
+
resolvedPath: safeFilename,
|
|
680
|
+
sizeBytes: Buffer.byteLength(safeContent, "utf8"),
|
|
681
|
+
contentType: contentType || guessArtifactContentType(safeFilename),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
483
685
|
getArtifactExportDirectory() {
|
|
484
686
|
return EXPORTS_DIR;
|
|
485
687
|
}
|
|
@@ -575,10 +777,19 @@ export class NodeSdkTransport {
|
|
|
575
777
|
}
|
|
576
778
|
|
|
577
779
|
emitLogEntry(entry) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
780
|
+
if (!this._logBatch) this._logBatch = [];
|
|
781
|
+
this._logBatch.push(entry);
|
|
782
|
+
if (!this._logBatchTimer) {
|
|
783
|
+
this._logBatchTimer = setTimeout(() => {
|
|
784
|
+
const batch = this._logBatch;
|
|
785
|
+
this._logBatch = [];
|
|
786
|
+
this._logBatchTimer = null;
|
|
787
|
+
for (const handler of this.logSubscribers) {
|
|
788
|
+
try {
|
|
789
|
+
handler(batch);
|
|
790
|
+
} catch {}
|
|
791
|
+
}
|
|
792
|
+
}, 250);
|
|
582
793
|
}
|
|
583
794
|
}
|
|
584
795
|
|
|
@@ -592,9 +803,208 @@ export class NodeSdkTransport {
|
|
|
592
803
|
}, 5000);
|
|
593
804
|
}
|
|
594
805
|
|
|
595
|
-
|
|
806
|
+
emitSyntheticLogMessage(message, level = "info", podName = "k8s") {
|
|
807
|
+
this.logEntryCounter += 1;
|
|
808
|
+
this.emitLogEntry(buildSyntheticLogEntry({
|
|
809
|
+
message,
|
|
810
|
+
level,
|
|
811
|
+
podName,
|
|
812
|
+
counter: this.logEntryCounter,
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async listPodsFromKubeApi(config, labelSelector) {
|
|
817
|
+
const params = new URLSearchParams();
|
|
818
|
+
if (labelSelector) params.set("labelSelector", labelSelector);
|
|
819
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
820
|
+
|
|
821
|
+
return await new Promise((resolve, reject) => {
|
|
822
|
+
const req = https.request({
|
|
823
|
+
method: "GET",
|
|
824
|
+
hostname: config.host,
|
|
825
|
+
port: config.port,
|
|
826
|
+
path: pathName,
|
|
827
|
+
ca: config.ca,
|
|
828
|
+
headers: {
|
|
829
|
+
Authorization: `Bearer ${config.token}`,
|
|
830
|
+
Accept: "application/json",
|
|
831
|
+
},
|
|
832
|
+
}, (res) => {
|
|
833
|
+
let body = "";
|
|
834
|
+
res.setEncoding("utf8");
|
|
835
|
+
res.on("data", (chunk) => {
|
|
836
|
+
body += chunk;
|
|
837
|
+
});
|
|
838
|
+
res.on("end", () => {
|
|
839
|
+
if ((res.statusCode || 0) >= 400) {
|
|
840
|
+
reject(new Error(
|
|
841
|
+
`Kubernetes API pod list failed (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
842
|
+
));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
const payload = JSON.parse(body || "{}");
|
|
847
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
848
|
+
resolve(items
|
|
849
|
+
.map((item) => String(item?.metadata?.name || "").trim())
|
|
850
|
+
.filter(Boolean));
|
|
851
|
+
} catch (error) {
|
|
852
|
+
reject(error);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
req.on("error", reject);
|
|
858
|
+
req.end();
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
streamPodLogsFromKubeApi(config, podName, handle, options = {}) {
|
|
863
|
+
const params = new URLSearchParams({
|
|
864
|
+
follow: "true",
|
|
865
|
+
timestamps: "true",
|
|
866
|
+
tailLines: String(options.tailLines ?? 500),
|
|
867
|
+
});
|
|
868
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`;
|
|
869
|
+
|
|
870
|
+
return new Promise((resolve, reject) => {
|
|
871
|
+
let buffer = "";
|
|
872
|
+
let settled = false;
|
|
873
|
+
let response = null;
|
|
874
|
+
|
|
875
|
+
const finish = (error = null) => {
|
|
876
|
+
if (settled) return;
|
|
877
|
+
settled = true;
|
|
878
|
+
|
|
879
|
+
if (buffer.trim()) {
|
|
880
|
+
this.logEntryCounter += 1;
|
|
881
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${buffer.trim()}`, this.logEntryCounter));
|
|
882
|
+
buffer = "";
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (response) {
|
|
886
|
+
handle.responses.delete(response);
|
|
887
|
+
}
|
|
888
|
+
handle.requests.delete(request);
|
|
889
|
+
|
|
890
|
+
if (error) reject(error);
|
|
891
|
+
else resolve();
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const request = https.request({
|
|
895
|
+
method: "GET",
|
|
896
|
+
hostname: config.host,
|
|
897
|
+
port: config.port,
|
|
898
|
+
path: pathName,
|
|
899
|
+
ca: config.ca,
|
|
900
|
+
headers: {
|
|
901
|
+
Authorization: `Bearer ${config.token}`,
|
|
902
|
+
Accept: "*/*",
|
|
903
|
+
},
|
|
904
|
+
}, (res) => {
|
|
905
|
+
response = res;
|
|
906
|
+
handle.responses.add(res);
|
|
907
|
+
|
|
908
|
+
if ((res.statusCode || 0) >= 400) {
|
|
909
|
+
let body = "";
|
|
910
|
+
res.setEncoding("utf8");
|
|
911
|
+
res.on("data", (chunk) => {
|
|
912
|
+
body += chunk;
|
|
913
|
+
});
|
|
914
|
+
res.on("end", () => {
|
|
915
|
+
finish(new Error(
|
|
916
|
+
`Kubernetes log stream failed for ${podName} (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
917
|
+
));
|
|
918
|
+
});
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
res.setEncoding("utf8");
|
|
923
|
+
res.on("data", (chunk) => {
|
|
924
|
+
buffer += chunk;
|
|
925
|
+
const lines = buffer.split("\n");
|
|
926
|
+
buffer = lines.pop() || "";
|
|
927
|
+
for (const line of lines) {
|
|
928
|
+
if (!line.trim()) continue;
|
|
929
|
+
this.logEntryCounter += 1;
|
|
930
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${line}`, this.logEntryCounter));
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
res.on("end", () => finish());
|
|
934
|
+
res.on("close", () => finish());
|
|
935
|
+
res.on("error", (error) => finish(error));
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
handle.requests.add(request);
|
|
939
|
+
request.on("error", (error) => finish(error));
|
|
940
|
+
request.end();
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
startInClusterLogProcess() {
|
|
945
|
+
const config = getInClusterK8sConfig();
|
|
946
|
+
if (!config || this.logTailHandle) return;
|
|
947
|
+
|
|
948
|
+
const labelSelector = process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
|
|
949
|
+
const handle = {
|
|
950
|
+
stopped: false,
|
|
951
|
+
requests: new Set(),
|
|
952
|
+
responses: new Set(),
|
|
953
|
+
stop: () => {
|
|
954
|
+
if (handle.stopped) return;
|
|
955
|
+
handle.stopped = true;
|
|
956
|
+
for (const response of handle.responses) {
|
|
957
|
+
try { response.destroy(); } catch {}
|
|
958
|
+
}
|
|
959
|
+
handle.responses.clear();
|
|
960
|
+
for (const request of handle.requests) {
|
|
961
|
+
try { request.destroy(); } catch {}
|
|
962
|
+
}
|
|
963
|
+
handle.requests.clear();
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
this.logTailHandle = handle;
|
|
967
|
+
|
|
968
|
+
this.listPodsFromKubeApi(config, labelSelector)
|
|
969
|
+
.then(async (podNames) => {
|
|
970
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
971
|
+
if (podNames.length === 0) {
|
|
972
|
+
this.emitSyntheticLogMessage(
|
|
973
|
+
`No pods matched label selector ${JSON.stringify(labelSelector)} in namespace ${config.namespace}.`,
|
|
974
|
+
"warn",
|
|
975
|
+
);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const results = await Promise.allSettled(
|
|
980
|
+
podNames.map((podName) => this.streamPodLogsFromKubeApi(config, podName, handle)),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
984
|
+
for (const result of results) {
|
|
985
|
+
if (result.status === "fulfilled") continue;
|
|
986
|
+
this.emitSyntheticLogMessage(result.reason?.message || String(result.reason), "error");
|
|
987
|
+
}
|
|
988
|
+
})
|
|
989
|
+
.catch((error) => {
|
|
990
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
991
|
+
this.emitSyntheticLogMessage(error?.message || String(error), "error");
|
|
992
|
+
})
|
|
993
|
+
.finally(() => {
|
|
994
|
+
if (this.logTailHandle === handle) {
|
|
995
|
+
this.logTailHandle = null;
|
|
996
|
+
}
|
|
997
|
+
if (!handle.stopped) {
|
|
998
|
+
this.scheduleLogRestart();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
startKubectlLogProcess() {
|
|
1004
|
+
if (this.logProc) return;
|
|
1005
|
+
|
|
596
1006
|
const config = this.getLogConfig();
|
|
597
|
-
if (!config.available
|
|
1007
|
+
if (!config.available) return;
|
|
598
1008
|
|
|
599
1009
|
const k8sContext = process.env.K8S_CONTEXT || "";
|
|
600
1010
|
const k8sNamespace = process.env.K8S_NAMESPACE || "copilot-runtime";
|
|
@@ -626,53 +1036,125 @@ export class NodeSdkTransport {
|
|
|
626
1036
|
this.logProc.stderr.on("data", (chunk) => {
|
|
627
1037
|
const text = stripAnsi(chunk.toString()).trim();
|
|
628
1038
|
if (!text) return;
|
|
629
|
-
this.
|
|
630
|
-
this.emitLogEntry({
|
|
631
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
632
|
-
time: extractLogTime(text),
|
|
633
|
-
podName: "kubectl",
|
|
634
|
-
level: "warn",
|
|
635
|
-
orchId: null,
|
|
636
|
-
category: "log",
|
|
637
|
-
rawLine: trimLogText(text),
|
|
638
|
-
message: trimLogText(text),
|
|
639
|
-
prettyMessage: trimLogText(text),
|
|
640
|
-
});
|
|
1039
|
+
this.emitSyntheticLogMessage(text, "warn", "kubectl");
|
|
641
1040
|
});
|
|
642
1041
|
|
|
643
1042
|
this.logProc.on("error", (error) => {
|
|
644
|
-
this.
|
|
645
|
-
this.emitLogEntry({
|
|
646
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
647
|
-
time: extractLogTime(""),
|
|
648
|
-
podName: "kubectl",
|
|
649
|
-
level: "error",
|
|
650
|
-
orchId: null,
|
|
651
|
-
category: "log",
|
|
652
|
-
rawLine: trimLogText(`kubectl error: ${error.message}`),
|
|
653
|
-
message: trimLogText(`kubectl error: ${error.message}`),
|
|
654
|
-
prettyMessage: trimLogText(`kubectl error: ${error.message}`),
|
|
655
|
-
});
|
|
1043
|
+
this.emitSyntheticLogMessage(`kubectl error: ${error.message}`, "error", "kubectl");
|
|
656
1044
|
});
|
|
657
1045
|
|
|
658
1046
|
this.logProc.on("exit", (code, signal) => {
|
|
659
1047
|
this.logProc = null;
|
|
660
|
-
this.
|
|
661
|
-
this.emitLogEntry({
|
|
662
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
663
|
-
time: extractLogTime(""),
|
|
664
|
-
podName: "kubectl",
|
|
665
|
-
level: "warn",
|
|
666
|
-
orchId: null,
|
|
667
|
-
category: "log",
|
|
668
|
-
rawLine: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
669
|
-
message: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
670
|
-
prettyMessage: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
671
|
-
});
|
|
1048
|
+
this.emitSyntheticLogMessage(`kubectl exited (code=${code} signal=${signal})`, "warn", "kubectl");
|
|
672
1049
|
this.scheduleLogRestart();
|
|
673
1050
|
});
|
|
674
1051
|
}
|
|
675
1052
|
|
|
1053
|
+
startLocalLogProcess() {
|
|
1054
|
+
const logDir = getLocalLogDir();
|
|
1055
|
+
if (!logDir || this.logTailHandle) return;
|
|
1056
|
+
|
|
1057
|
+
const handle = {
|
|
1058
|
+
stopped: false,
|
|
1059
|
+
files: new Map(),
|
|
1060
|
+
interval: null,
|
|
1061
|
+
stop: () => {
|
|
1062
|
+
if (handle.stopped) return;
|
|
1063
|
+
handle.stopped = true;
|
|
1064
|
+
if (handle.interval) {
|
|
1065
|
+
clearInterval(handle.interval);
|
|
1066
|
+
handle.interval = null;
|
|
1067
|
+
}
|
|
1068
|
+
handle.files.clear();
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
this.logTailHandle = handle;
|
|
1072
|
+
|
|
1073
|
+
const emitLine = (filePath, line) => {
|
|
1074
|
+
const text = String(line || "").trim();
|
|
1075
|
+
if (!text) return;
|
|
1076
|
+
const pseudoPod = path.basename(filePath, path.extname(filePath));
|
|
1077
|
+
this.logEntryCounter += 1;
|
|
1078
|
+
this.emitLogEntry(buildLogEntry(`[pod/${pseudoPod}] ${text}`, this.logEntryCounter));
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const refresh = () => {
|
|
1082
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
1083
|
+
for (const filePath of listLocalLogFiles(logDir)) {
|
|
1084
|
+
let state = handle.files.get(filePath);
|
|
1085
|
+
let stats;
|
|
1086
|
+
try {
|
|
1087
|
+
stats = fs.statSync(filePath);
|
|
1088
|
+
} catch {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (!stats.isFile()) continue;
|
|
1092
|
+
|
|
1093
|
+
if (!state) {
|
|
1094
|
+
state = {
|
|
1095
|
+
position: stats.size,
|
|
1096
|
+
inode: stats.ino,
|
|
1097
|
+
buffer: "",
|
|
1098
|
+
};
|
|
1099
|
+
handle.files.set(filePath, state);
|
|
1100
|
+
for (const line of readRecentLogLines(filePath)) {
|
|
1101
|
+
emitLine(filePath, line);
|
|
1102
|
+
}
|
|
1103
|
+
state.position = stats.size;
|
|
1104
|
+
state.inode = stats.ino;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (state.inode !== stats.ino || stats.size < state.position) {
|
|
1109
|
+
state.position = 0;
|
|
1110
|
+
state.buffer = "";
|
|
1111
|
+
state.inode = stats.ino;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (stats.size <= state.position) continue;
|
|
1115
|
+
|
|
1116
|
+
const chunk = readLogChunk(filePath, state.position, stats.size);
|
|
1117
|
+
state.position = stats.size;
|
|
1118
|
+
if (!chunk) continue;
|
|
1119
|
+
const combined = state.buffer + chunk;
|
|
1120
|
+
const lines = combined.split(/\r?\n/u);
|
|
1121
|
+
state.buffer = lines.pop() || "";
|
|
1122
|
+
for (const line of lines) {
|
|
1123
|
+
emitLine(filePath, line);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
refresh();
|
|
1130
|
+
handle.interval = setInterval(refresh, getLocalLogPollIntervalMs());
|
|
1131
|
+
if (typeof handle.interval.unref === "function") {
|
|
1132
|
+
handle.interval.unref();
|
|
1133
|
+
}
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
this.logTailHandle = null;
|
|
1136
|
+
handle.stop();
|
|
1137
|
+
this.emitSyntheticLogMessage(error?.message || String(error), "error", "local-log");
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
startLogProcess() {
|
|
1142
|
+
const config = this.getLogConfig();
|
|
1143
|
+
if (!config.available || this.logProc || this.logTailHandle) return;
|
|
1144
|
+
|
|
1145
|
+
if (getLocalLogDir()) {
|
|
1146
|
+
this.startLocalLogProcess();
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (hasInClusterK8sAccess()) {
|
|
1151
|
+
this.startInClusterLogProcess();
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
this.startKubectlLogProcess();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
676
1158
|
startLogTail(handler) {
|
|
677
1159
|
if (typeof handler === "function") {
|
|
678
1160
|
this.logSubscribers.add(handler);
|
|
@@ -690,10 +1172,21 @@ export class NodeSdkTransport {
|
|
|
690
1172
|
}
|
|
691
1173
|
|
|
692
1174
|
async stopLogTail() {
|
|
1175
|
+
if (this._logBatchTimer) {
|
|
1176
|
+
clearTimeout(this._logBatchTimer);
|
|
1177
|
+
this._logBatchTimer = null;
|
|
1178
|
+
this._logBatch = [];
|
|
1179
|
+
}
|
|
693
1180
|
if (this.logRestartTimer) {
|
|
694
1181
|
clearTimeout(this.logRestartTimer);
|
|
695
1182
|
this.logRestartTimer = null;
|
|
696
1183
|
}
|
|
1184
|
+
if (this.logTailHandle) {
|
|
1185
|
+
try {
|
|
1186
|
+
this.logTailHandle.stop();
|
|
1187
|
+
} catch {}
|
|
1188
|
+
this.logTailHandle = null;
|
|
1189
|
+
}
|
|
697
1190
|
if (this.logProc) {
|
|
698
1191
|
try {
|
|
699
1192
|
this.logProc.kill("SIGKILL");
|