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.
Files changed (48) hide show
  1. package/README.md +3 -0
  2. package/bin/tui.js +8 -2
  3. package/node_modules/pilotswarm-ui-core/README.md +6 -0
  4. package/node_modules/pilotswarm-ui-core/package.json +32 -0
  5. package/node_modules/pilotswarm-ui-core/src/commands.js +72 -0
  6. package/node_modules/pilotswarm-ui-core/src/context-usage.js +212 -0
  7. package/node_modules/pilotswarm-ui-core/src/controller.js +3676 -0
  8. package/node_modules/pilotswarm-ui-core/src/formatting.js +872 -0
  9. package/node_modules/pilotswarm-ui-core/src/history.js +589 -0
  10. package/node_modules/pilotswarm-ui-core/src/index.js +13 -0
  11. package/node_modules/pilotswarm-ui-core/src/layout.js +196 -0
  12. package/node_modules/pilotswarm-ui-core/src/reducer.js +1030 -0
  13. package/node_modules/pilotswarm-ui-core/src/selectors.js +2921 -0
  14. package/node_modules/pilotswarm-ui-core/src/session-tree.js +109 -0
  15. package/node_modules/pilotswarm-ui-core/src/state.js +80 -0
  16. package/node_modules/pilotswarm-ui-core/src/store.js +23 -0
  17. package/node_modules/pilotswarm-ui-core/src/system-titles.js +24 -0
  18. package/node_modules/pilotswarm-ui-core/src/themes/catppuccin-mocha.js +56 -0
  19. package/node_modules/pilotswarm-ui-core/src/themes/cobalt2.js +56 -0
  20. package/node_modules/pilotswarm-ui-core/src/themes/dark-high-contrast.js +56 -0
  21. package/node_modules/pilotswarm-ui-core/src/themes/dracula.js +56 -0
  22. package/node_modules/pilotswarm-ui-core/src/themes/github-dark.js +56 -0
  23. package/node_modules/pilotswarm-ui-core/src/themes/gruvbox-dark.js +56 -0
  24. package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-matrix.js +56 -0
  25. package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-orion-prime.js +56 -0
  26. package/node_modules/pilotswarm-ui-core/src/themes/helpers.js +77 -0
  27. package/node_modules/pilotswarm-ui-core/src/themes/index.js +44 -0
  28. package/node_modules/pilotswarm-ui-core/src/themes/noctis-obscuro.js +56 -0
  29. package/node_modules/pilotswarm-ui-core/src/themes/noctis-viola.js +56 -0
  30. package/node_modules/pilotswarm-ui-core/src/themes/noctis.js +56 -0
  31. package/node_modules/pilotswarm-ui-core/src/themes/nord.js +56 -0
  32. package/node_modules/pilotswarm-ui-core/src/themes/solarized-dark.js +56 -0
  33. package/node_modules/pilotswarm-ui-core/src/themes/tokyo-night.js +56 -0
  34. package/node_modules/pilotswarm-ui-react/README.md +5 -0
  35. package/node_modules/pilotswarm-ui-react/package.json +36 -0
  36. package/node_modules/pilotswarm-ui-react/src/components.js +1413 -0
  37. package/node_modules/pilotswarm-ui-react/src/index.js +4 -0
  38. package/node_modules/pilotswarm-ui-react/src/platform.js +15 -0
  39. package/node_modules/pilotswarm-ui-react/src/use-controller-state.js +38 -0
  40. package/node_modules/pilotswarm-ui-react/src/web-app.js +2759 -0
  41. package/package.json +9 -3
  42. package/src/app.js +44 -0
  43. package/src/bootstrap-env.js +1 -37
  44. package/src/node-sdk-transport.js +555 -62
  45. package/src/platform.js +43 -11
  46. package/src/plugin-config.js +239 -0
  47. package/src/portal.js +7 -0
  48. 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.join(os.homedir(), "pilotswarm-exports");
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 orchId = orchMatch ? orchMatch[2] || orchMatch[1] : null;
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 failed terminal orchestration and cannot accept new messages.`;
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 hasK8sConfig = Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
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: hasK8sConfig,
320
- availabilityReason: hasK8sConfig
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
- for (const handler of this.logSubscribers) {
579
- try {
580
- handler(entry);
581
- } catch {}
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
- startLogProcess() {
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 || this.logProc) return;
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.logEntryCounter += 1;
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.logEntryCounter += 1;
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.logEntryCounter += 1;
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");