pilotswarm-cli 0.1.13 → 0.1.15
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 +5 -2
- package/package.json +4 -3
- package/src/app.js +55 -0
- package/src/bootstrap-env.js +1 -37
- package/src/index.js +67 -0
- package/src/node-sdk-transport.js +422 -61
- package/src/platform.js +90 -35
- package/src/plugin-config.js +239 -0
- package/src/portal.js +7 -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,65 @@ 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
16
|
const EXPORTS_DIR = path.join(os.homedir(), "pilotswarm-exports");
|
|
15
17
|
fs.mkdirSync(EXPORTS_DIR, { recursive: true });
|
|
18
|
+
const K8S_SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
19
|
+
|
|
20
|
+
function fileExists(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.existsSync(filePath);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getInClusterK8sPaths() {
|
|
29
|
+
const baseDir = process.env.PILOTSWARM_K8S_SERVICE_ACCOUNT_DIR || K8S_SERVICE_ACCOUNT_DIR;
|
|
30
|
+
return {
|
|
31
|
+
tokenPath: process.env.PILOTSWARM_K8S_TOKEN_PATH || path.join(baseDir, "token"),
|
|
32
|
+
caPath: process.env.PILOTSWARM_K8S_CA_PATH || path.join(baseDir, "ca.crt"),
|
|
33
|
+
namespacePath: process.env.PILOTSWARM_K8S_NAMESPACE_PATH || path.join(baseDir, "namespace"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readOptionalTextFile(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasInClusterK8sAccess() {
|
|
46
|
+
const { tokenPath, caPath } = getInClusterK8sPaths();
|
|
47
|
+
return Boolean(process.env.KUBERNETES_SERVICE_HOST)
|
|
48
|
+
&& fileExists(tokenPath)
|
|
49
|
+
&& fileExists(caPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getInClusterK8sConfig() {
|
|
53
|
+
if (!hasInClusterK8sAccess()) return null;
|
|
54
|
+
|
|
55
|
+
const { tokenPath, caPath, namespacePath } = getInClusterK8sPaths();
|
|
56
|
+
return {
|
|
57
|
+
host: String(process.env.KUBERNETES_SERVICE_HOST || "").trim(),
|
|
58
|
+
port: Number(process.env.KUBERNETES_SERVICE_PORT || 443) || 443,
|
|
59
|
+
token: readOptionalTextFile(tokenPath),
|
|
60
|
+
ca: fs.readFileSync(caPath),
|
|
61
|
+
namespace: String(process.env.K8S_NAMESPACE || "").trim() || readOptionalTextFile(namespacePath) || "default",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExplicitKubectlConfig() {
|
|
66
|
+
return Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isKubectlAvailable() {
|
|
70
|
+
const result = spawnSync("kubectl", ["version", "--client=true"], { stdio: "ignore" });
|
|
71
|
+
return !result.error;
|
|
72
|
+
}
|
|
16
73
|
|
|
17
74
|
function stripAnsi(value) {
|
|
18
75
|
return String(value || "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -105,9 +162,14 @@ function buildLogEntry(line, counter) {
|
|
|
105
162
|
const prefixMatch = line.match(/^\[pod\/([^/\]]+)/);
|
|
106
163
|
const podName = prefixMatch ? prefixMatch[1] : "unknown";
|
|
107
164
|
const rawLine = trimLogText(stripAnsi(line.replace(/^\[pod\/[^\]]+\]\s*/, "")).trim());
|
|
108
|
-
const orchMatch = rawLine.match(/\b(instance_id|orchestration_id)=(session-[^\s,]+)/i)
|
|
165
|
+
const orchMatch = rawLine.match(/\b(?:instance_id|orchestration_id|orch)=(session-[^\s,]+)/i)
|
|
109
166
|
|| rawLine.match(/\b(session-[0-9a-f-]{8,})\b/i);
|
|
110
|
-
const
|
|
167
|
+
const parsedOrchId = orchMatch ? orchMatch[1] : null;
|
|
168
|
+
const sessionIdMatch = rawLine.match(/\b(?:sessionId|session|durableSessionId)=([0-9a-f-]{8,})\b/i);
|
|
169
|
+
const sessionId = sessionIdMatch
|
|
170
|
+
? sessionIdMatch[1]
|
|
171
|
+
: (parsedOrchId && parsedOrchId.startsWith("session-") ? parsedOrchId.slice("session-".length) : null);
|
|
172
|
+
const orchId = parsedOrchId || (sessionId ? `session-${sessionId}` : null);
|
|
111
173
|
const category = rawLine.includes("duroxide::activity")
|
|
112
174
|
? "activity"
|
|
113
175
|
: rawLine.includes("duroxide::orchestration") || rawLine.includes("::orchestration")
|
|
@@ -120,6 +182,7 @@ function buildLogEntry(line, counter) {
|
|
|
120
182
|
podName,
|
|
121
183
|
level: normalizeLogLevel(rawLine),
|
|
122
184
|
orchId,
|
|
185
|
+
sessionId,
|
|
123
186
|
category,
|
|
124
187
|
rawLine,
|
|
125
188
|
message: extractPrettyLogMessage(rawLine),
|
|
@@ -127,6 +190,22 @@ function buildLogEntry(line, counter) {
|
|
|
127
190
|
};
|
|
128
191
|
}
|
|
129
192
|
|
|
193
|
+
function buildSyntheticLogEntry({ message, level = "info", podName = "k8s", counter = 0 }) {
|
|
194
|
+
const safeMessage = trimLogText(String(message || "").trim());
|
|
195
|
+
return {
|
|
196
|
+
id: `log:${Date.now()}:${counter}`,
|
|
197
|
+
time: extractLogTime(safeMessage),
|
|
198
|
+
podName,
|
|
199
|
+
level,
|
|
200
|
+
orchId: null,
|
|
201
|
+
sessionId: null,
|
|
202
|
+
category: "log",
|
|
203
|
+
rawLine: safeMessage,
|
|
204
|
+
message: safeMessage,
|
|
205
|
+
prettyMessage: safeMessage,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
130
209
|
function sanitizeArtifactFilename(filename) {
|
|
131
210
|
return String(filename || "").replace(/[/\\]/g, "_");
|
|
132
211
|
}
|
|
@@ -179,13 +258,6 @@ function isTerminalSendError(error) {
|
|
|
179
258
|
return /instance is terminal|terminal orchestration|cannot accept new messages/i.test(message);
|
|
180
259
|
}
|
|
181
260
|
|
|
182
|
-
function getPluginDirsFromEnv() {
|
|
183
|
-
return String(process.env.PLUGIN_DIRS || "")
|
|
184
|
-
.split(",")
|
|
185
|
-
.map((value) => value.trim())
|
|
186
|
-
.filter(Boolean);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
261
|
function normalizeCreatableAgent(agent) {
|
|
190
262
|
const name = String(agent?.name || "").trim();
|
|
191
263
|
if (!name) return null;
|
|
@@ -235,8 +307,8 @@ function loadSessionCreationMetadataFromPluginDirs(pluginDirs = []) {
|
|
|
235
307
|
}
|
|
236
308
|
|
|
237
309
|
function buildTerminalSendError(sessionId, session) {
|
|
238
|
-
if (session?.status === "failed" || session?.orchestrationStatus === "Failed") {
|
|
239
|
-
return `Session ${sessionId.slice(0, 8)} is a
|
|
310
|
+
if (session?.status === "failed" || session?.status === "cancelled" || session?.orchestrationStatus === "Failed") {
|
|
311
|
+
return `Session ${sessionId.slice(0, 8)} is a terminal orchestration and cannot accept new messages.`;
|
|
240
312
|
}
|
|
241
313
|
|
|
242
314
|
const statusLabel = String(session?.orchestrationStatus || session?.status || "Unknown");
|
|
@@ -256,10 +328,12 @@ export class NodeSdkTransport {
|
|
|
256
328
|
this.allowedAgentNames = [];
|
|
257
329
|
this.creatableAgents = [];
|
|
258
330
|
this.logProc = null;
|
|
331
|
+
this.logTailHandle = null;
|
|
259
332
|
this.logBuffer = "";
|
|
260
333
|
this.logRestartTimer = null;
|
|
261
334
|
this.logSubscribers = new Set();
|
|
262
335
|
this.logEntryCounter = 0;
|
|
336
|
+
this.kubectlAvailable = null;
|
|
263
337
|
}
|
|
264
338
|
|
|
265
339
|
async start() {
|
|
@@ -314,12 +388,30 @@ export class NodeSdkTransport {
|
|
|
314
388
|
}
|
|
315
389
|
|
|
316
390
|
getLogConfig() {
|
|
317
|
-
const
|
|
391
|
+
const hasInClusterConfig = hasInClusterK8sAccess();
|
|
392
|
+
const hasKubectlConfig = hasExplicitKubectlConfig();
|
|
393
|
+
if (hasInClusterConfig) {
|
|
394
|
+
return {
|
|
395
|
+
available: true,
|
|
396
|
+
availabilityReason: "",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (hasKubectlConfig) {
|
|
401
|
+
if (this.kubectlAvailable == null) {
|
|
402
|
+
this.kubectlAvailable = isKubectlAvailable();
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
available: this.kubectlAvailable,
|
|
406
|
+
availabilityReason: this.kubectlAvailable
|
|
407
|
+
? ""
|
|
408
|
+
: "Log tailing disabled: kubectl is not installed in this environment.",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
318
412
|
return {
|
|
319
|
-
available:
|
|
320
|
-
availabilityReason:
|
|
321
|
-
? ""
|
|
322
|
-
: "Log tailing disabled: no K8S_CONTEXT configured in the env file.",
|
|
413
|
+
available: false,
|
|
414
|
+
availabilityReason: "Log tailing disabled: no K8S_CONTEXT/KUBECONFIG or in-cluster Kubernetes access detected.",
|
|
323
415
|
};
|
|
324
416
|
}
|
|
325
417
|
|
|
@@ -335,6 +427,10 @@ export class NodeSdkTransport {
|
|
|
335
427
|
return this.mgmt.getOrchestrationStats(sessionId);
|
|
336
428
|
}
|
|
337
429
|
|
|
430
|
+
async getExecutionHistory(sessionId, executionId) {
|
|
431
|
+
return this.mgmt.getExecutionHistory(sessionId, executionId);
|
|
432
|
+
}
|
|
433
|
+
|
|
338
434
|
async createSession({ model } = {}) {
|
|
339
435
|
const effectiveModel = model || this.mgmt.getDefaultModel();
|
|
340
436
|
const session = await this.client.createSession(effectiveModel ? { model: effectiveModel } : undefined);
|
|
@@ -371,11 +467,11 @@ export class NodeSdkTransport {
|
|
|
371
467
|
if (!session) {
|
|
372
468
|
throw new Error(`Session ${sessionId.slice(0, 8)} was not found.`);
|
|
373
469
|
}
|
|
374
|
-
if (session.status === "failed" || session.orchestrationStatus === "Failed") {
|
|
470
|
+
if (session.status === "failed" || session.status === "cancelled" || session.orchestrationStatus === "Failed") {
|
|
375
471
|
throw new Error(buildTerminalSendError(sessionId, session));
|
|
376
472
|
}
|
|
377
473
|
if (
|
|
378
|
-
session.status === "completed"
|
|
474
|
+
(session.status === "completed" || session.status === "cancelled")
|
|
379
475
|
&& session.parentSessionId
|
|
380
476
|
&& !session.isSystem
|
|
381
477
|
&& !session.cronActive
|
|
@@ -476,6 +572,36 @@ export class NodeSdkTransport {
|
|
|
476
572
|
};
|
|
477
573
|
}
|
|
478
574
|
|
|
575
|
+
async uploadArtifactContent(sessionId, filename, content, contentType = guessArtifactContentType(filename)) {
|
|
576
|
+
if (!this.artifactStore) {
|
|
577
|
+
throw new Error("Artifact store is not available for this transport.");
|
|
578
|
+
}
|
|
579
|
+
const safeSessionId = String(sessionId || "").trim();
|
|
580
|
+
const safeFilename = path.basename(String(filename || "").trim());
|
|
581
|
+
const safeContent = typeof content === "string" ? content : String(content || "");
|
|
582
|
+
if (!safeSessionId) {
|
|
583
|
+
throw new Error("Session id is required for artifact upload.");
|
|
584
|
+
}
|
|
585
|
+
if (!safeFilename) {
|
|
586
|
+
throw new Error("Filename is required for artifact upload.");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await this.artifactStore.uploadArtifact(
|
|
590
|
+
safeSessionId,
|
|
591
|
+
safeFilename,
|
|
592
|
+
safeContent,
|
|
593
|
+
contentType || guessArtifactContentType(safeFilename),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
sessionId: safeSessionId,
|
|
598
|
+
filename: safeFilename,
|
|
599
|
+
resolvedPath: safeFilename,
|
|
600
|
+
sizeBytes: Buffer.byteLength(safeContent, "utf8"),
|
|
601
|
+
contentType: contentType || guessArtifactContentType(safeFilename),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
479
605
|
getArtifactExportDirectory() {
|
|
480
606
|
return EXPORTS_DIR;
|
|
481
607
|
}
|
|
@@ -495,6 +621,43 @@ export class NodeSdkTransport {
|
|
|
495
621
|
};
|
|
496
622
|
}
|
|
497
623
|
|
|
624
|
+
async exportExecutionHistory(sessionId) {
|
|
625
|
+
if (!this.artifactStore) {
|
|
626
|
+
throw new Error("Artifact store is not available for this transport.");
|
|
627
|
+
}
|
|
628
|
+
const shortId = String(sessionId || "").slice(0, 8);
|
|
629
|
+
const [history, stats] = await Promise.all([
|
|
630
|
+
this.mgmt.getExecutionHistory(sessionId),
|
|
631
|
+
this.mgmt.getOrchestrationStats(sessionId),
|
|
632
|
+
]);
|
|
633
|
+
const sessionInfo = await this.mgmt.getSession(sessionId).catch(() => null);
|
|
634
|
+
const exportData = {
|
|
635
|
+
exportedAt: new Date().toISOString(),
|
|
636
|
+
sessionId,
|
|
637
|
+
title: sessionInfo?.title || null,
|
|
638
|
+
agentId: sessionInfo?.agentId || null,
|
|
639
|
+
model: sessionInfo?.model || null,
|
|
640
|
+
orchestrationStats: stats || null,
|
|
641
|
+
eventCount: history?.length || 0,
|
|
642
|
+
events: (history || []).map((e) => {
|
|
643
|
+
const evt = { ...e };
|
|
644
|
+
if (evt.data) {
|
|
645
|
+
try { evt.data = JSON.parse(evt.data); } catch { /* keep raw */ }
|
|
646
|
+
}
|
|
647
|
+
return evt;
|
|
648
|
+
}),
|
|
649
|
+
};
|
|
650
|
+
const filename = `execution-history-${shortId}-${Date.now()}.json`;
|
|
651
|
+
const content = JSON.stringify(exportData, null, 2);
|
|
652
|
+
await this.artifactStore.uploadArtifact(sessionId, filename, content, guessArtifactContentType(filename));
|
|
653
|
+
return {
|
|
654
|
+
sessionId,
|
|
655
|
+
filename,
|
|
656
|
+
artifactLink: `artifact://${sessionId}/${filename}`,
|
|
657
|
+
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
498
661
|
async openPathInDefaultApp(targetPath) {
|
|
499
662
|
const resolvedPath = path.resolve(expandUserPath(targetPath));
|
|
500
663
|
if (!resolvedPath) {
|
|
@@ -534,10 +697,19 @@ export class NodeSdkTransport {
|
|
|
534
697
|
}
|
|
535
698
|
|
|
536
699
|
emitLogEntry(entry) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
700
|
+
if (!this._logBatch) this._logBatch = [];
|
|
701
|
+
this._logBatch.push(entry);
|
|
702
|
+
if (!this._logBatchTimer) {
|
|
703
|
+
this._logBatchTimer = setTimeout(() => {
|
|
704
|
+
const batch = this._logBatch;
|
|
705
|
+
this._logBatch = [];
|
|
706
|
+
this._logBatchTimer = null;
|
|
707
|
+
for (const handler of this.logSubscribers) {
|
|
708
|
+
try {
|
|
709
|
+
handler(batch);
|
|
710
|
+
} catch {}
|
|
711
|
+
}
|
|
712
|
+
}, 250);
|
|
541
713
|
}
|
|
542
714
|
}
|
|
543
715
|
|
|
@@ -551,9 +723,208 @@ export class NodeSdkTransport {
|
|
|
551
723
|
}, 5000);
|
|
552
724
|
}
|
|
553
725
|
|
|
554
|
-
|
|
726
|
+
emitSyntheticLogMessage(message, level = "info", podName = "k8s") {
|
|
727
|
+
this.logEntryCounter += 1;
|
|
728
|
+
this.emitLogEntry(buildSyntheticLogEntry({
|
|
729
|
+
message,
|
|
730
|
+
level,
|
|
731
|
+
podName,
|
|
732
|
+
counter: this.logEntryCounter,
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async listPodsFromKubeApi(config, labelSelector) {
|
|
737
|
+
const params = new URLSearchParams();
|
|
738
|
+
if (labelSelector) params.set("labelSelector", labelSelector);
|
|
739
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
740
|
+
|
|
741
|
+
return await new Promise((resolve, reject) => {
|
|
742
|
+
const req = https.request({
|
|
743
|
+
method: "GET",
|
|
744
|
+
hostname: config.host,
|
|
745
|
+
port: config.port,
|
|
746
|
+
path: pathName,
|
|
747
|
+
ca: config.ca,
|
|
748
|
+
headers: {
|
|
749
|
+
Authorization: `Bearer ${config.token}`,
|
|
750
|
+
Accept: "application/json",
|
|
751
|
+
},
|
|
752
|
+
}, (res) => {
|
|
753
|
+
let body = "";
|
|
754
|
+
res.setEncoding("utf8");
|
|
755
|
+
res.on("data", (chunk) => {
|
|
756
|
+
body += chunk;
|
|
757
|
+
});
|
|
758
|
+
res.on("end", () => {
|
|
759
|
+
if ((res.statusCode || 0) >= 400) {
|
|
760
|
+
reject(new Error(
|
|
761
|
+
`Kubernetes API pod list failed (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
762
|
+
));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const payload = JSON.parse(body || "{}");
|
|
767
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
768
|
+
resolve(items
|
|
769
|
+
.map((item) => String(item?.metadata?.name || "").trim())
|
|
770
|
+
.filter(Boolean));
|
|
771
|
+
} catch (error) {
|
|
772
|
+
reject(error);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
req.on("error", reject);
|
|
778
|
+
req.end();
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
streamPodLogsFromKubeApi(config, podName, handle, options = {}) {
|
|
783
|
+
const params = new URLSearchParams({
|
|
784
|
+
follow: "true",
|
|
785
|
+
timestamps: "true",
|
|
786
|
+
tailLines: String(options.tailLines ?? 500),
|
|
787
|
+
});
|
|
788
|
+
const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`;
|
|
789
|
+
|
|
790
|
+
return new Promise((resolve, reject) => {
|
|
791
|
+
let buffer = "";
|
|
792
|
+
let settled = false;
|
|
793
|
+
let response = null;
|
|
794
|
+
|
|
795
|
+
const finish = (error = null) => {
|
|
796
|
+
if (settled) return;
|
|
797
|
+
settled = true;
|
|
798
|
+
|
|
799
|
+
if (buffer.trim()) {
|
|
800
|
+
this.logEntryCounter += 1;
|
|
801
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${buffer.trim()}`, this.logEntryCounter));
|
|
802
|
+
buffer = "";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (response) {
|
|
806
|
+
handle.responses.delete(response);
|
|
807
|
+
}
|
|
808
|
+
handle.requests.delete(request);
|
|
809
|
+
|
|
810
|
+
if (error) reject(error);
|
|
811
|
+
else resolve();
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const request = https.request({
|
|
815
|
+
method: "GET",
|
|
816
|
+
hostname: config.host,
|
|
817
|
+
port: config.port,
|
|
818
|
+
path: pathName,
|
|
819
|
+
ca: config.ca,
|
|
820
|
+
headers: {
|
|
821
|
+
Authorization: `Bearer ${config.token}`,
|
|
822
|
+
Accept: "*/*",
|
|
823
|
+
},
|
|
824
|
+
}, (res) => {
|
|
825
|
+
response = res;
|
|
826
|
+
handle.responses.add(res);
|
|
827
|
+
|
|
828
|
+
if ((res.statusCode || 0) >= 400) {
|
|
829
|
+
let body = "";
|
|
830
|
+
res.setEncoding("utf8");
|
|
831
|
+
res.on("data", (chunk) => {
|
|
832
|
+
body += chunk;
|
|
833
|
+
});
|
|
834
|
+
res.on("end", () => {
|
|
835
|
+
finish(new Error(
|
|
836
|
+
`Kubernetes log stream failed for ${podName} (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
|
|
837
|
+
));
|
|
838
|
+
});
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
res.setEncoding("utf8");
|
|
843
|
+
res.on("data", (chunk) => {
|
|
844
|
+
buffer += chunk;
|
|
845
|
+
const lines = buffer.split("\n");
|
|
846
|
+
buffer = lines.pop() || "";
|
|
847
|
+
for (const line of lines) {
|
|
848
|
+
if (!line.trim()) continue;
|
|
849
|
+
this.logEntryCounter += 1;
|
|
850
|
+
this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${line}`, this.logEntryCounter));
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
res.on("end", () => finish());
|
|
854
|
+
res.on("close", () => finish());
|
|
855
|
+
res.on("error", (error) => finish(error));
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
handle.requests.add(request);
|
|
859
|
+
request.on("error", (error) => finish(error));
|
|
860
|
+
request.end();
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
startInClusterLogProcess() {
|
|
865
|
+
const config = getInClusterK8sConfig();
|
|
866
|
+
if (!config || this.logTailHandle) return;
|
|
867
|
+
|
|
868
|
+
const labelSelector = process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
|
|
869
|
+
const handle = {
|
|
870
|
+
stopped: false,
|
|
871
|
+
requests: new Set(),
|
|
872
|
+
responses: new Set(),
|
|
873
|
+
stop: () => {
|
|
874
|
+
if (handle.stopped) return;
|
|
875
|
+
handle.stopped = true;
|
|
876
|
+
for (const response of handle.responses) {
|
|
877
|
+
try { response.destroy(); } catch {}
|
|
878
|
+
}
|
|
879
|
+
handle.responses.clear();
|
|
880
|
+
for (const request of handle.requests) {
|
|
881
|
+
try { request.destroy(); } catch {}
|
|
882
|
+
}
|
|
883
|
+
handle.requests.clear();
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
this.logTailHandle = handle;
|
|
887
|
+
|
|
888
|
+
this.listPodsFromKubeApi(config, labelSelector)
|
|
889
|
+
.then(async (podNames) => {
|
|
890
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
891
|
+
if (podNames.length === 0) {
|
|
892
|
+
this.emitSyntheticLogMessage(
|
|
893
|
+
`No pods matched label selector ${JSON.stringify(labelSelector)} in namespace ${config.namespace}.`,
|
|
894
|
+
"warn",
|
|
895
|
+
);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const results = await Promise.allSettled(
|
|
900
|
+
podNames.map((podName) => this.streamPodLogsFromKubeApi(config, podName, handle)),
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
904
|
+
for (const result of results) {
|
|
905
|
+
if (result.status === "fulfilled") continue;
|
|
906
|
+
this.emitSyntheticLogMessage(result.reason?.message || String(result.reason), "error");
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
.catch((error) => {
|
|
910
|
+
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
911
|
+
this.emitSyntheticLogMessage(error?.message || String(error), "error");
|
|
912
|
+
})
|
|
913
|
+
.finally(() => {
|
|
914
|
+
if (this.logTailHandle === handle) {
|
|
915
|
+
this.logTailHandle = null;
|
|
916
|
+
}
|
|
917
|
+
if (!handle.stopped) {
|
|
918
|
+
this.scheduleLogRestart();
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
startKubectlLogProcess() {
|
|
924
|
+
if (this.logProc) return;
|
|
925
|
+
|
|
555
926
|
const config = this.getLogConfig();
|
|
556
|
-
if (!config.available
|
|
927
|
+
if (!config.available) return;
|
|
557
928
|
|
|
558
929
|
const k8sContext = process.env.K8S_CONTEXT || "";
|
|
559
930
|
const k8sNamespace = process.env.K8S_NAMESPACE || "copilot-runtime";
|
|
@@ -585,53 +956,32 @@ export class NodeSdkTransport {
|
|
|
585
956
|
this.logProc.stderr.on("data", (chunk) => {
|
|
586
957
|
const text = stripAnsi(chunk.toString()).trim();
|
|
587
958
|
if (!text) return;
|
|
588
|
-
this.
|
|
589
|
-
this.emitLogEntry({
|
|
590
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
591
|
-
time: extractLogTime(text),
|
|
592
|
-
podName: "kubectl",
|
|
593
|
-
level: "warn",
|
|
594
|
-
orchId: null,
|
|
595
|
-
category: "log",
|
|
596
|
-
rawLine: trimLogText(text),
|
|
597
|
-
message: trimLogText(text),
|
|
598
|
-
prettyMessage: trimLogText(text),
|
|
599
|
-
});
|
|
959
|
+
this.emitSyntheticLogMessage(text, "warn", "kubectl");
|
|
600
960
|
});
|
|
601
961
|
|
|
602
962
|
this.logProc.on("error", (error) => {
|
|
603
|
-
this.
|
|
604
|
-
this.emitLogEntry({
|
|
605
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
606
|
-
time: extractLogTime(""),
|
|
607
|
-
podName: "kubectl",
|
|
608
|
-
level: "error",
|
|
609
|
-
orchId: null,
|
|
610
|
-
category: "log",
|
|
611
|
-
rawLine: trimLogText(`kubectl error: ${error.message}`),
|
|
612
|
-
message: trimLogText(`kubectl error: ${error.message}`),
|
|
613
|
-
prettyMessage: trimLogText(`kubectl error: ${error.message}`),
|
|
614
|
-
});
|
|
963
|
+
this.emitSyntheticLogMessage(`kubectl error: ${error.message}`, "error", "kubectl");
|
|
615
964
|
});
|
|
616
965
|
|
|
617
966
|
this.logProc.on("exit", (code, signal) => {
|
|
618
967
|
this.logProc = null;
|
|
619
|
-
this.
|
|
620
|
-
this.emitLogEntry({
|
|
621
|
-
id: `log:${Date.now()}:${this.logEntryCounter}`,
|
|
622
|
-
time: extractLogTime(""),
|
|
623
|
-
podName: "kubectl",
|
|
624
|
-
level: "warn",
|
|
625
|
-
orchId: null,
|
|
626
|
-
category: "log",
|
|
627
|
-
rawLine: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
628
|
-
message: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
629
|
-
prettyMessage: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
|
|
630
|
-
});
|
|
968
|
+
this.emitSyntheticLogMessage(`kubectl exited (code=${code} signal=${signal})`, "warn", "kubectl");
|
|
631
969
|
this.scheduleLogRestart();
|
|
632
970
|
});
|
|
633
971
|
}
|
|
634
972
|
|
|
973
|
+
startLogProcess() {
|
|
974
|
+
const config = this.getLogConfig();
|
|
975
|
+
if (!config.available || this.logProc || this.logTailHandle) return;
|
|
976
|
+
|
|
977
|
+
if (hasInClusterK8sAccess()) {
|
|
978
|
+
this.startInClusterLogProcess();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
this.startKubectlLogProcess();
|
|
983
|
+
}
|
|
984
|
+
|
|
635
985
|
startLogTail(handler) {
|
|
636
986
|
if (typeof handler === "function") {
|
|
637
987
|
this.logSubscribers.add(handler);
|
|
@@ -649,10 +999,21 @@ export class NodeSdkTransport {
|
|
|
649
999
|
}
|
|
650
1000
|
|
|
651
1001
|
async stopLogTail() {
|
|
1002
|
+
if (this._logBatchTimer) {
|
|
1003
|
+
clearTimeout(this._logBatchTimer);
|
|
1004
|
+
this._logBatchTimer = null;
|
|
1005
|
+
this._logBatch = [];
|
|
1006
|
+
}
|
|
652
1007
|
if (this.logRestartTimer) {
|
|
653
1008
|
clearTimeout(this.logRestartTimer);
|
|
654
1009
|
this.logRestartTimer = null;
|
|
655
1010
|
}
|
|
1011
|
+
if (this.logTailHandle) {
|
|
1012
|
+
try {
|
|
1013
|
+
this.logTailHandle.stop();
|
|
1014
|
+
} catch {}
|
|
1015
|
+
this.logTailHandle = null;
|
|
1016
|
+
}
|
|
656
1017
|
if (this.logProc) {
|
|
657
1018
|
try {
|
|
658
1019
|
this.logProc.kill("SIGKILL");
|