@tilt-launcher/sdk 1.2.0
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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/bridge.d.ts +43 -0
- package/dist/bridge.js +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +771 -0
- package/dist/tiltManagerSDK.d.ts +102 -0
- package/dist/tiltManagerSDK.js +771 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +0 -0
- package/package.json +42 -0
- package/src/bridge.ts +45 -0
- package/src/index.ts +3 -0
- package/src/tiltManagerSDK.ts +924 -0
- package/src/types.ts +171 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname } from 'node:path';
|
|
6
|
+
import type {
|
|
7
|
+
CachedResource,
|
|
8
|
+
Config,
|
|
9
|
+
DiscoverResult,
|
|
10
|
+
Environment,
|
|
11
|
+
LogDelta,
|
|
12
|
+
ResourceRow,
|
|
13
|
+
StatusResponse,
|
|
14
|
+
StatusUpdate,
|
|
15
|
+
} from './types.ts';
|
|
16
|
+
|
|
17
|
+
type EnvState = 'running' | 'starting' | 'stopped';
|
|
18
|
+
|
|
19
|
+
interface TiltManagerSDKOptions {
|
|
20
|
+
maxLogLines?: number;
|
|
21
|
+
/** @deprecated Use onStatusUpdate + onLogDelta instead */
|
|
22
|
+
onStatus?: (snapshot: StatusResponse) => void;
|
|
23
|
+
/** Push status updates (resources / env state — no logs) */
|
|
24
|
+
onStatusUpdate?: (update: StatusUpdate) => void;
|
|
25
|
+
/** Push incremental log appends */
|
|
26
|
+
onLogDelta?: (delta: LogDelta) => void;
|
|
27
|
+
/** Called when the SDK mutates config in-memory (e.g. cachedResources for external envs). */
|
|
28
|
+
onConfigMutated?: (config: Config) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TiltManagerSDK {
|
|
32
|
+
private config: Config;
|
|
33
|
+
private readonly maxLogLines: number;
|
|
34
|
+
private readonly onStatus: ((snapshot: StatusResponse) => void) | undefined;
|
|
35
|
+
private readonly onStatusUpdate: ((update: StatusUpdate) => void) | undefined;
|
|
36
|
+
private readonly onLogDelta: ((delta: LogDelta) => void) | undefined;
|
|
37
|
+
private readonly onConfigMutated: ((config: Config) => void) | undefined;
|
|
38
|
+
/** Tracks how many env log lines have been emitted per env */
|
|
39
|
+
private readonly emittedEnvLogIndex = new Map<string, number>();
|
|
40
|
+
/** Tracks how many resource log lines have been emitted per key */
|
|
41
|
+
private readonly emittedResourceLogIndex = new Map<string, number>();
|
|
42
|
+
|
|
43
|
+
private readonly processes = new Map<string, ChildProcess>();
|
|
44
|
+
private readonly logs = new Map<string, string[]>();
|
|
45
|
+
private readonly startTimes = new Map<string, number>();
|
|
46
|
+
private readonly discoveredResources = new Map<string, CachedResource[]>();
|
|
47
|
+
private readonly tiltPortReachable = new Map<string, boolean>();
|
|
48
|
+
private readonly healthByKey = new Map<string, ResourceRow['health']>();
|
|
49
|
+
private readonly newResourceCount = new Map<string, number>();
|
|
50
|
+
private readonly resourceLogProcesses = new Map<string, ChildProcess>();
|
|
51
|
+
private readonly resourceLogs = new Map<string, string[]>();
|
|
52
|
+
private pollHandle: NodeJS.Timeout | null = null;
|
|
53
|
+
// WebSocket streaming
|
|
54
|
+
private readonly wsConnections = new Map<string, WebSocket>();
|
|
55
|
+
private readonly wsReconnectTimers = new Map<string, NodeJS.Timeout>();
|
|
56
|
+
private wsEnabled = false;
|
|
57
|
+
|
|
58
|
+
constructor(config: Config, options?: TiltManagerSDKOptions) {
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.maxLogLines = options?.maxLogLines ?? 800;
|
|
61
|
+
this.onStatus = options?.onStatus;
|
|
62
|
+
this.onStatusUpdate = options?.onStatusUpdate;
|
|
63
|
+
this.onLogDelta = options?.onLogDelta;
|
|
64
|
+
this.onConfigMutated = options?.onConfigMutated;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setConfig(next: Config): void {
|
|
68
|
+
this.config = next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Full snapshot including all logs — used for initial fetch or legacy compatibility */
|
|
72
|
+
currentStatusSnapshot(): StatusResponse {
|
|
73
|
+
const envs: StatusResponse['envs'] = {};
|
|
74
|
+
for (const env of this.config.environments) {
|
|
75
|
+
envs[env.id] = {
|
|
76
|
+
status: this.getEnvState(env),
|
|
77
|
+
logs: this.logs.get(env.id) ?? [],
|
|
78
|
+
resourceLogs: this.getResourceLogsForEnv(env.id),
|
|
79
|
+
tiltPort: env.tiltPort,
|
|
80
|
+
uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
|
|
81
|
+
newResources: this.newResourceCount.get(env.id) ?? 0,
|
|
82
|
+
resources: this.getDisplayRows(env),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { envs };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Log-free status snapshot for all environments */
|
|
89
|
+
currentStatusUpdate(): StatusUpdate {
|
|
90
|
+
const envs: StatusUpdate['envs'] = {};
|
|
91
|
+
for (const env of this.config.environments) {
|
|
92
|
+
envs[env.id] = {
|
|
93
|
+
status: this.getEnvState(env),
|
|
94
|
+
tiltPort: env.tiltPort,
|
|
95
|
+
uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
|
|
96
|
+
newResources: this.newResourceCount.get(env.id) ?? 0,
|
|
97
|
+
resources: this.getDisplayRows(env),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { envs };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Full log snapshot for a single environment */
|
|
104
|
+
getEnvLogs(envId: string): { envLogs: string[]; resourceLogs: Record<string, string[]> } {
|
|
105
|
+
return {
|
|
106
|
+
envLogs: this.logs.get(envId) ?? [],
|
|
107
|
+
resourceLogs: this.getResourceLogsForEnv(envId),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
startPolling(intervalMs = 5000): void {
|
|
112
|
+
this.wsEnabled = true;
|
|
113
|
+
if (this.pollHandle) clearInterval(this.pollHandle);
|
|
114
|
+
this.pollHandle = setInterval(() => {
|
|
115
|
+
void this.pollTiltState();
|
|
116
|
+
}, intervalMs);
|
|
117
|
+
void this.pollTiltState();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stopPolling(): void {
|
|
121
|
+
this.wsEnabled = false;
|
|
122
|
+
if (!this.pollHandle) return;
|
|
123
|
+
clearInterval(this.pollHandle);
|
|
124
|
+
this.pollHandle = null;
|
|
125
|
+
// Disconnect all WebSockets
|
|
126
|
+
for (const envId of this.wsConnections.keys()) {
|
|
127
|
+
this.disconnectWebSocket(envId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
startEnv(envId: string): { ok: boolean; error?: string } {
|
|
132
|
+
const env = this.envById(envId);
|
|
133
|
+
if (!env) return { ok: false, error: 'Unknown environment.' };
|
|
134
|
+
if (env.external) return { ok: false, error: 'Cannot start an external environment.' };
|
|
135
|
+
const state = this.getEnvState(env);
|
|
136
|
+
if (state === 'running' || state === 'starting') return { ok: false, error: 'Environment already active.' };
|
|
137
|
+
|
|
138
|
+
this.appendLog(env.id, `[launcher] Starting ${env.name}...`);
|
|
139
|
+
this.appendLog(env.id, `[launcher] tilt up -f ${env.tiltfile} --port ${env.tiltPort}`);
|
|
140
|
+
const child = spawn('tilt', ['up', '-f', env.tiltfile, '--port', String(env.tiltPort)], {
|
|
141
|
+
cwd: env.repoDir,
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
144
|
+
env: { ...process.env, PWD: env.repoDir },
|
|
145
|
+
});
|
|
146
|
+
child.unref();
|
|
147
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
148
|
+
for (const line of chunk.toString().split('\n').filter(Boolean)) this.appendLog(env.id, line);
|
|
149
|
+
});
|
|
150
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
151
|
+
for (const line of chunk.toString().split('\n').filter(Boolean)) this.appendLog(env.id, line);
|
|
152
|
+
});
|
|
153
|
+
child.on('close', (code) => {
|
|
154
|
+
this.appendLog(env.id, `[launcher] Process exited with code ${code ?? 0}`);
|
|
155
|
+
this.processes.delete(env.id);
|
|
156
|
+
this.emitStatus();
|
|
157
|
+
});
|
|
158
|
+
child.on('error', (error: Error) => {
|
|
159
|
+
this.appendLog(env.id, `[launcher] ${error.message}`);
|
|
160
|
+
this.processes.delete(env.id);
|
|
161
|
+
this.emitStatus();
|
|
162
|
+
});
|
|
163
|
+
this.processes.set(env.id, child);
|
|
164
|
+
this.startTimes.set(env.id, Date.now());
|
|
165
|
+
this.emitStatus();
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
stopEnv(envId: string): { ok: boolean; error?: string } {
|
|
170
|
+
const env = this.envById(envId);
|
|
171
|
+
if (!env) return { ok: false, error: 'Unknown environment.' };
|
|
172
|
+
this.appendLog(env.id, `[launcher] Stopping ${env.name}...`);
|
|
173
|
+
|
|
174
|
+
const tracked = this.processes.get(env.id);
|
|
175
|
+
if (tracked) {
|
|
176
|
+
try {
|
|
177
|
+
tracked.kill('SIGTERM');
|
|
178
|
+
} catch {
|
|
179
|
+
// already stopped
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const cwd = env.repoDir || homedir();
|
|
183
|
+
void this.runCommand('tilt', ['down', '--port', String(env.tiltPort)], cwd);
|
|
184
|
+
this.processes.delete(env.id);
|
|
185
|
+
this.startTimes.delete(env.id);
|
|
186
|
+
this.tiltPortReachable.delete(env.id);
|
|
187
|
+
this.discoveredResources.delete(env.id);
|
|
188
|
+
// Clear stale health entries for this env
|
|
189
|
+
for (const key of this.healthByKey.keys()) {
|
|
190
|
+
if (key.startsWith(`${env.id}:`)) this.healthByKey.delete(key);
|
|
191
|
+
}
|
|
192
|
+
this.disconnectWebSocket(env.id);
|
|
193
|
+
this.stopResourceLogStreams(env.id);
|
|
194
|
+
this.emitStatus();
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
restartEnv(envId: string): { ok: boolean; error?: string } {
|
|
199
|
+
const env = this.envById(envId);
|
|
200
|
+
if (env?.external) return { ok: false, error: 'Cannot restart an external environment.' };
|
|
201
|
+
const stopped = this.stopEnv(envId);
|
|
202
|
+
if (!stopped.ok) return stopped;
|
|
203
|
+
return this.startEnv(envId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async triggerResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
|
|
207
|
+
return await this.runResourceCommand(envId, ['trigger', resourceName]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async enableResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
|
|
211
|
+
return await this.runResourceCommand(envId, ['enable', resourceName]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async disableResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
|
|
215
|
+
return await this.runResourceCommand(envId, ['disable', resourceName]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async discoverResources(input: {
|
|
219
|
+
tiltfilePath: string;
|
|
220
|
+
tiltPort: number;
|
|
221
|
+
timeoutMs?: number;
|
|
222
|
+
}): Promise<DiscoverResult> {
|
|
223
|
+
const repoDir = dirname(input.tiltfilePath);
|
|
224
|
+
const tiltfile = basename(input.tiltfilePath);
|
|
225
|
+
const timeoutMs = input.timeoutMs ?? 30000;
|
|
226
|
+
const logsOut: string[] = [];
|
|
227
|
+
|
|
228
|
+
let discoveryProc: ChildProcess;
|
|
229
|
+
try {
|
|
230
|
+
discoveryProc = spawn('tilt', ['up', '-f', tiltfile, '--port', String(input.tiltPort)], {
|
|
231
|
+
cwd: repoDir,
|
|
232
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
233
|
+
env: { ...process.env, PWD: repoDir },
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const message = error instanceof Error ? error.message : 'Unknown process launch error';
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
resources: [],
|
|
240
|
+
logs: logsOut,
|
|
241
|
+
error: `Failed to start Tilt for discovery: ${message}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
let spawnError: Error | null = null;
|
|
245
|
+
|
|
246
|
+
discoveryProc.stdout?.on('data', (chunk: Buffer) => {
|
|
247
|
+
logsOut.push(...chunk.toString().split('\n').filter(Boolean));
|
|
248
|
+
});
|
|
249
|
+
discoveryProc.stderr?.on('data', (chunk: Buffer) => {
|
|
250
|
+
logsOut.push(...chunk.toString().split('\n').filter(Boolean));
|
|
251
|
+
});
|
|
252
|
+
discoveryProc.once('error', (error: Error) => {
|
|
253
|
+
spawnError = error;
|
|
254
|
+
logsOut.push(`[launcher] ${error.message}`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const startedAt = Date.now();
|
|
258
|
+
let resources: CachedResource[] | null = null;
|
|
259
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
260
|
+
if (spawnError) break;
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
262
|
+
const env: Environment = {
|
|
263
|
+
id: 'discovery',
|
|
264
|
+
name: 'Discovery',
|
|
265
|
+
repoDir,
|
|
266
|
+
tiltfile,
|
|
267
|
+
tiltPort: input.tiltPort,
|
|
268
|
+
selectedResources: [],
|
|
269
|
+
cachedResources: [],
|
|
270
|
+
};
|
|
271
|
+
resources = await this.readTiltResources(env);
|
|
272
|
+
if (resources && resources.length > 0) break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (spawnError) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
resources: [],
|
|
279
|
+
logs: logsOut,
|
|
280
|
+
error: `Failed to start Tilt for discovery: ${(spawnError as Error)?.message ?? 'Unknown error'}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
void this.runCommand('tilt', ['down', '--port', String(input.tiltPort)], repoDir);
|
|
285
|
+
if (discoveryProc.pid) {
|
|
286
|
+
try {
|
|
287
|
+
process.kill(-discoveryProc.pid, 'SIGTERM');
|
|
288
|
+
} catch {
|
|
289
|
+
discoveryProc.kill('SIGTERM');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!resources || resources.length === 0) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
resources: [],
|
|
297
|
+
logs: logsOut,
|
|
298
|
+
error:
|
|
299
|
+
'No resources found. The Tiltfile may have only defined the Tiltfile itself, or it failed to start within the discovery timeout.',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { ok: true, resources, logs: logsOut };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private emitStatus(): void {
|
|
307
|
+
// Legacy full-snapshot callback
|
|
308
|
+
this.onStatus?.(this.currentStatusSnapshot());
|
|
309
|
+
|
|
310
|
+
// New split callbacks
|
|
311
|
+
if (this.onStatusUpdate) {
|
|
312
|
+
this.onStatusUpdate(this.currentStatusUpdate());
|
|
313
|
+
}
|
|
314
|
+
if (this.onLogDelta) {
|
|
315
|
+
const envLogs: LogDelta['envLogs'] = {};
|
|
316
|
+
const resourceLogs: LogDelta['resourceLogs'] = {};
|
|
317
|
+
let hasData = false;
|
|
318
|
+
|
|
319
|
+
for (const env of this.config.environments) {
|
|
320
|
+
const allEnvLines = this.logs.get(env.id) ?? [];
|
|
321
|
+
const prevEnv = this.emittedEnvLogIndex.get(env.id) ?? 0;
|
|
322
|
+
if (allEnvLines.length > prevEnv) {
|
|
323
|
+
envLogs[env.id] = allEnvLines.slice(prevEnv);
|
|
324
|
+
this.emittedEnvLogIndex.set(env.id, allEnvLines.length);
|
|
325
|
+
hasData = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const [key, lines] of this.resourceLogs) {
|
|
330
|
+
const prev = this.emittedResourceLogIndex.get(key) ?? 0;
|
|
331
|
+
if (lines.length > prev) {
|
|
332
|
+
resourceLogs[key] = lines.slice(prev);
|
|
333
|
+
this.emittedResourceLogIndex.set(key, lines.length);
|
|
334
|
+
hasData = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (hasData) {
|
|
339
|
+
this.onLogDelta({ envLogs, resourceLogs });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private appendLog(envId: string, line: string): void {
|
|
345
|
+
const existing = this.logs.get(envId) ?? [];
|
|
346
|
+
existing.push(line);
|
|
347
|
+
this.logs.set(envId, existing.slice(-this.maxLogLines));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private getResourceLogsForEnv(envId: string): Record<string, string[]> {
|
|
351
|
+
const result: Record<string, string[]> = {};
|
|
352
|
+
const prefix = `${envId}:`;
|
|
353
|
+
for (const [key, lines] of this.resourceLogs) {
|
|
354
|
+
if (key.startsWith(prefix)) {
|
|
355
|
+
result[key.slice(prefix.length)] = lines;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private startResourceLogStreams(env: Environment): void {
|
|
362
|
+
const resources = this.discoveredResources.get(env.id) ?? [];
|
|
363
|
+
for (const resource of resources) {
|
|
364
|
+
const key = `${env.id}:${resource.name}`;
|
|
365
|
+
if (this.resourceLogProcesses.has(key)) continue; // already streaming
|
|
366
|
+
|
|
367
|
+
const child = spawn('tilt', ['logs', '-f', resource.name, '--port', String(env.tiltPort)], {
|
|
368
|
+
cwd: env.repoDir,
|
|
369
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
370
|
+
env: { ...process.env, PWD: env.repoDir },
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const appendResourceLog = (line: string): void => {
|
|
374
|
+
const existing = this.resourceLogs.get(key) ?? [];
|
|
375
|
+
existing.push(line);
|
|
376
|
+
if (existing.length > this.maxLogLines) {
|
|
377
|
+
this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
|
|
378
|
+
} else {
|
|
379
|
+
this.resourceLogs.set(key, existing);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
384
|
+
for (const line of chunk.toString().split('\n').filter(Boolean)) appendResourceLog(line);
|
|
385
|
+
});
|
|
386
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
387
|
+
for (const line of chunk.toString().split('\n').filter(Boolean)) appendResourceLog(line);
|
|
388
|
+
});
|
|
389
|
+
child.on('close', () => {
|
|
390
|
+
this.resourceLogProcesses.delete(key);
|
|
391
|
+
});
|
|
392
|
+
child.on('error', () => {
|
|
393
|
+
this.resourceLogProcesses.delete(key);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.resourceLogProcesses.set(key, child);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private stopResourceLogStreams(envId: string): void {
|
|
401
|
+
const prefix = `${envId}:`;
|
|
402
|
+
for (const [key, proc] of this.resourceLogProcesses) {
|
|
403
|
+
if (key.startsWith(prefix)) {
|
|
404
|
+
try {
|
|
405
|
+
proc.kill('SIGTERM');
|
|
406
|
+
} catch {
|
|
407
|
+
/* already dead */
|
|
408
|
+
}
|
|
409
|
+
this.resourceLogProcesses.delete(key);
|
|
410
|
+
this.resourceLogs.delete(key);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private envById(envId: string): Environment | undefined {
|
|
416
|
+
return this.config.environments.find((env) => env.id === envId);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private getEnvState(env: Environment): EnvState {
|
|
420
|
+
const resources = this.discoveredResources.get(env.id) ?? [];
|
|
421
|
+
if (resources.some((resource) => resource.runtimeStatus === 'ok')) return 'running';
|
|
422
|
+
if (this.tiltPortReachable.get(env.id)) return 'running';
|
|
423
|
+
// External envs have no tracked process — status is purely port-based
|
|
424
|
+
if (env.external) return 'stopped';
|
|
425
|
+
const proc = this.processes.get(env.id);
|
|
426
|
+
if (proc && proc.exitCode === null && !proc.killed) return 'starting';
|
|
427
|
+
return 'stopped';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private parseEndpoint(endpoint?: string): { protocol?: string; hostname?: string; port?: number; path?: string } {
|
|
431
|
+
if (!endpoint) return {};
|
|
432
|
+
try {
|
|
433
|
+
const url = new URL(endpoint);
|
|
434
|
+
return {
|
|
435
|
+
protocol: url.protocol,
|
|
436
|
+
hostname: url.hostname,
|
|
437
|
+
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
|
438
|
+
path: url.pathname || '/',
|
|
439
|
+
};
|
|
440
|
+
} catch {
|
|
441
|
+
return {};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private absoluteEndpoint(endpoint: string | undefined, env: Environment): string | undefined {
|
|
446
|
+
if (!endpoint) return undefined;
|
|
447
|
+
try {
|
|
448
|
+
return new URL(endpoint).toString();
|
|
449
|
+
} catch {
|
|
450
|
+
try {
|
|
451
|
+
return new URL(endpoint, `http://localhost:${env.tiltPort}`).toString();
|
|
452
|
+
} catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private categoryFor(resource: CachedResource): string {
|
|
459
|
+
if (resource.category) return resource.category;
|
|
460
|
+
if (resource.runtimeStatus === 'not_applicable') return 'on-demand';
|
|
461
|
+
return 'services';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private async runCommand(command: string, args: string[], cwd: string): Promise<{ code: number; output: string }> {
|
|
465
|
+
return await new Promise((resolve) => {
|
|
466
|
+
const child = spawn(command, args, { cwd, env: { ...process.env, PWD: cwd } });
|
|
467
|
+
let output = '';
|
|
468
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
469
|
+
output += chunk.toString();
|
|
470
|
+
});
|
|
471
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
472
|
+
output += chunk.toString();
|
|
473
|
+
});
|
|
474
|
+
child.on('close', (code) => resolve({ code: code ?? 1, output }));
|
|
475
|
+
child.on('error', (err: Error) => resolve({ code: 1, output: `${output}\n${err.message}` }));
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private async runResourceCommand(envId: string, args: string[]): Promise<{ ok: boolean; error?: string }> {
|
|
480
|
+
const env = this.envById(envId);
|
|
481
|
+
if (!env) return { ok: false, error: 'Unknown environment.' };
|
|
482
|
+
const command = ['tilt', ...args, '--port', String(env.tiltPort)].join(' ');
|
|
483
|
+
this.appendLog(env.id, `[launcher] ${command}`);
|
|
484
|
+
const cwd = env.repoDir || homedir();
|
|
485
|
+
const result = await this.runCommand('tilt', [...args, '--port', String(env.tiltPort)], cwd);
|
|
486
|
+
if (result.code !== 0) {
|
|
487
|
+
const detail = result.output.trim();
|
|
488
|
+
if (detail) this.appendLog(env.id, detail);
|
|
489
|
+
this.emitStatus();
|
|
490
|
+
return { ok: false, error: detail || `Command failed: ${command}` };
|
|
491
|
+
}
|
|
492
|
+
const detail = result.output.trim();
|
|
493
|
+
if (detail) this.appendLog(env.id, detail);
|
|
494
|
+
await this.pollTiltState();
|
|
495
|
+
return { ok: true };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
499
|
+
private parseUIResource(item: any, env: Environment): CachedResource {
|
|
500
|
+
const endpoint = this.absoluteEndpoint(item.status?.endpointLinks?.[0]?.url, env);
|
|
501
|
+
const parsedEndpoint = this.parseEndpoint(endpoint);
|
|
502
|
+
const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
|
|
503
|
+
const disableState = (item.status?.disableStatus?.state ?? '').toLowerCase();
|
|
504
|
+
const isDisabled = disableState === 'disabled' || disableState === 'pending';
|
|
505
|
+
const runtimeStatus = isDisabled ? 'disabled' : (item.status?.runtimeStatus ?? 'unknown');
|
|
506
|
+
const resourceKind =
|
|
507
|
+
runtimeStatus === 'not_applicable' || runtimeStatus === 'disabled'
|
|
508
|
+
? ('cmd' as const)
|
|
509
|
+
: runtimeStatus === 'ok' || runtimeStatus === 'pending' || runtimeStatus === 'error'
|
|
510
|
+
? ('serve' as const)
|
|
511
|
+
: ('unknown' as const);
|
|
512
|
+
|
|
513
|
+
// Build history
|
|
514
|
+
const buildHistory = item.status?.buildHistory as
|
|
515
|
+
| Array<{
|
|
516
|
+
startTime?: string;
|
|
517
|
+
finishTime?: string;
|
|
518
|
+
error?: string;
|
|
519
|
+
}>
|
|
520
|
+
| undefined;
|
|
521
|
+
const lastBuild = buildHistory?.[0];
|
|
522
|
+
const lastBuildDuration =
|
|
523
|
+
lastBuild?.startTime && lastBuild?.finishTime
|
|
524
|
+
? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000
|
|
525
|
+
: undefined;
|
|
526
|
+
|
|
527
|
+
// Waiting state
|
|
528
|
+
const waiting = item.status?.waiting as
|
|
529
|
+
| {
|
|
530
|
+
reason?: string;
|
|
531
|
+
on?: Array<{ name?: string }>;
|
|
532
|
+
}
|
|
533
|
+
| undefined;
|
|
534
|
+
|
|
535
|
+
// Conditions
|
|
536
|
+
const rawConditions = item.status?.conditions as
|
|
537
|
+
| Array<{
|
|
538
|
+
type?: string;
|
|
539
|
+
status?: string;
|
|
540
|
+
lastTransitionTime?: string;
|
|
541
|
+
}>
|
|
542
|
+
| undefined;
|
|
543
|
+
const conditions = rawConditions?.map((c) => ({
|
|
544
|
+
type: c.type ?? '',
|
|
545
|
+
status: c.status ?? '',
|
|
546
|
+
...(c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}),
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
// PID (comes as a string in WebSocket, number in CLI)
|
|
550
|
+
const rawPid = item.status?.localResourceInfo?.pid;
|
|
551
|
+
const pid = rawPid ? Number(rawPid) : undefined;
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
name: item.metadata?.name ?? 'unknown',
|
|
555
|
+
label: item.metadata?.name ?? 'unknown',
|
|
556
|
+
category: labels[0] ?? 'services',
|
|
557
|
+
type: item.status?.specs?.[0]?.type ?? 'unknown',
|
|
558
|
+
endpoint,
|
|
559
|
+
port: parsedEndpoint.port,
|
|
560
|
+
path: parsedEndpoint.path,
|
|
561
|
+
runtimeStatus,
|
|
562
|
+
isDisabled,
|
|
563
|
+
resourceKind,
|
|
564
|
+
updateStatus: item.status?.updateStatus,
|
|
565
|
+
waitingReason: waiting?.reason,
|
|
566
|
+
waitingOn: waiting?.on?.map((ref: { name?: string }) => ref.name ?? '').filter(Boolean),
|
|
567
|
+
lastDeployTime: item.status?.lastDeployTime,
|
|
568
|
+
lastBuildDuration,
|
|
569
|
+
lastBuildError: lastBuild?.error,
|
|
570
|
+
hasPendingChanges: item.status?.hasPendingChanges,
|
|
571
|
+
triggerMode: item.status?.triggerMode,
|
|
572
|
+
queued: item.status?.queued,
|
|
573
|
+
order: item.status?.order,
|
|
574
|
+
pid,
|
|
575
|
+
conditions,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async readTiltResources(env: Environment): Promise<CachedResource[] | null> {
|
|
580
|
+
const result = await this.runCommand(
|
|
581
|
+
'tilt',
|
|
582
|
+
['get', 'uiresources', '-o', 'json', '--port', String(env.tiltPort)],
|
|
583
|
+
env.repoDir,
|
|
584
|
+
);
|
|
585
|
+
if (result.code !== 0) return null;
|
|
586
|
+
try {
|
|
587
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
588
|
+
const parsed = JSON.parse(result.output) as { items?: any[] };
|
|
589
|
+
return (parsed.items ?? [])
|
|
590
|
+
.filter((item) => item.metadata?.name && item.metadata.name !== '(Tiltfile)')
|
|
591
|
+
.map((item) => this.parseUIResource(item, env));
|
|
592
|
+
} catch {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async tryConnect(
|
|
598
|
+
hostname: string,
|
|
599
|
+
port: number,
|
|
600
|
+
path = '/',
|
|
601
|
+
protocol: 'http:' | 'https:' = 'http:',
|
|
602
|
+
): Promise<boolean> {
|
|
603
|
+
return await new Promise((resolve) => {
|
|
604
|
+
const client = protocol === 'https:' ? https : http;
|
|
605
|
+
const request = client.request(
|
|
606
|
+
{
|
|
607
|
+
hostname,
|
|
608
|
+
port,
|
|
609
|
+
path,
|
|
610
|
+
method: 'GET',
|
|
611
|
+
timeout: 1500,
|
|
612
|
+
},
|
|
613
|
+
(response) => {
|
|
614
|
+
response.resume();
|
|
615
|
+
resolve(true);
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
request.on('error', () => resolve(false));
|
|
619
|
+
request.on('timeout', () => {
|
|
620
|
+
request.destroy();
|
|
621
|
+
resolve(false);
|
|
622
|
+
});
|
|
623
|
+
request.end();
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private runtimeHealth(resource: CachedResource): ResourceRow['health'] {
|
|
628
|
+
if (resource.isDisabled) return 'unknown';
|
|
629
|
+
const runtime = (resource.runtimeStatus ?? '').toLowerCase();
|
|
630
|
+
if (runtime === 'ok') return 'up';
|
|
631
|
+
if (runtime === 'not_applicable' || runtime === '') return 'unknown';
|
|
632
|
+
return 'down';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private async computeHealth(resource: CachedResource, env: Environment): Promise<ResourceRow['health']> {
|
|
636
|
+
const runtimeDerived = this.runtimeHealth(resource);
|
|
637
|
+
if (resource.isDisabled) return runtimeDerived;
|
|
638
|
+
|
|
639
|
+
// Trust Tilt's runtime status — if Tilt says "ok", it's up.
|
|
640
|
+
// Probes only supplement by upgrading 'unknown' to 'up', never downgrade.
|
|
641
|
+
if (runtimeDerived === 'up' || runtimeDerived === 'down') return runtimeDerived;
|
|
642
|
+
|
|
643
|
+
const parsed = this.parseEndpoint(resource.endpoint);
|
|
644
|
+
const path = parsed.path ?? resource.path ?? '/';
|
|
645
|
+
const protocol = parsed.protocol === 'https:' ? 'https:' : 'http:';
|
|
646
|
+
|
|
647
|
+
// Tilt often exposes resource links through its own dashboard proxy port.
|
|
648
|
+
// Probing that port only confirms Tilt is up, not the underlying service.
|
|
649
|
+
if (parsed.hostname === 'localhost' && parsed.port === env.tiltPort) {
|
|
650
|
+
return runtimeDerived;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (parsed.hostname && parsed.port) {
|
|
654
|
+
const direct = await this.tryConnect(parsed.hostname, parsed.port, path, protocol);
|
|
655
|
+
if (direct) return 'up';
|
|
656
|
+
if (parsed.hostname === 'localhost') {
|
|
657
|
+
const loopback =
|
|
658
|
+
(await this.tryConnect('127.0.0.1', parsed.port, path, protocol)) ||
|
|
659
|
+
(await this.tryConnect('::1', parsed.port, path, protocol));
|
|
660
|
+
if (loopback) return 'up';
|
|
661
|
+
}
|
|
662
|
+
return runtimeDerived;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!resource.port) return runtimeDerived;
|
|
666
|
+
const ok =
|
|
667
|
+
(await this.tryConnect('127.0.0.1', resource.port, path, protocol)) ||
|
|
668
|
+
(await this.tryConnect('::1', resource.port, path, protocol));
|
|
669
|
+
if (ok) return 'up';
|
|
670
|
+
return runtimeDerived;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private getDisplayRows(env: Environment): ResourceRow[] {
|
|
674
|
+
const selected = env.selectedResources ?? [];
|
|
675
|
+
const discovered = this.discoveredResources.get(env.id) ?? [];
|
|
676
|
+
const cached = env.cachedResources ?? [];
|
|
677
|
+
const byName = new Map<string, CachedResource>();
|
|
678
|
+
for (const resource of cached) byName.set(resource.name, resource);
|
|
679
|
+
for (const resource of discovered) byName.set(resource.name, resource);
|
|
680
|
+
|
|
681
|
+
return selected.map((name) => {
|
|
682
|
+
const key = `${env.id}:${name}`;
|
|
683
|
+
const found = byName.get(name);
|
|
684
|
+
if (!found) {
|
|
685
|
+
return {
|
|
686
|
+
key,
|
|
687
|
+
name,
|
|
688
|
+
label: name,
|
|
689
|
+
category: 'services',
|
|
690
|
+
runtimeStatus: 'missing',
|
|
691
|
+
isDisabled: false,
|
|
692
|
+
health: 'missing',
|
|
693
|
+
exists: false,
|
|
694
|
+
error: `Resource '${name}' not found in Tiltfile output.`,
|
|
695
|
+
resourceKind: 'unknown',
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
key,
|
|
700
|
+
name: found.name,
|
|
701
|
+
label: found.label || found.name,
|
|
702
|
+
category: this.categoryFor(found),
|
|
703
|
+
endpoint: found.endpoint,
|
|
704
|
+
port: found.port,
|
|
705
|
+
path: found.path,
|
|
706
|
+
runtimeStatus: found.runtimeStatus ?? 'unknown',
|
|
707
|
+
isDisabled: found.isDisabled ?? false,
|
|
708
|
+
health: this.healthByKey.get(key) ?? 'unknown',
|
|
709
|
+
exists: true,
|
|
710
|
+
resourceKind: found.resourceKind ?? 'unknown',
|
|
711
|
+
updateStatus: found.updateStatus,
|
|
712
|
+
waitingReason: found.waitingReason,
|
|
713
|
+
waitingOn: found.waitingOn,
|
|
714
|
+
lastDeployTime: found.lastDeployTime,
|
|
715
|
+
lastBuildDuration: found.lastBuildDuration,
|
|
716
|
+
lastBuildError: found.lastBuildError,
|
|
717
|
+
hasPendingChanges: found.hasPendingChanges,
|
|
718
|
+
triggerMode: found.triggerMode,
|
|
719
|
+
queued: found.queued,
|
|
720
|
+
order: found.order,
|
|
721
|
+
pid: found.pid,
|
|
722
|
+
conditions: found.conditions,
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ─── WebSocket streaming ──────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
private async connectWebSocket(env: Environment): Promise<void> {
|
|
730
|
+
if (this.wsConnections.has(env.id)) return; // already connected
|
|
731
|
+
|
|
732
|
+
const tokenUrl = `http://127.0.0.1:${env.tiltPort}/api/websocket_token`;
|
|
733
|
+
let token: string;
|
|
734
|
+
try {
|
|
735
|
+
const resp = await fetch(tokenUrl);
|
|
736
|
+
if (!resp.ok) return;
|
|
737
|
+
token = (await resp.text()).trim();
|
|
738
|
+
} catch {
|
|
739
|
+
return; // Tilt not ready yet
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const wsUrl = `ws://127.0.0.1:${env.tiltPort}/ws/view?token=${token}`;
|
|
743
|
+
const ws = new WebSocket(wsUrl);
|
|
744
|
+
|
|
745
|
+
ws.onopen = () => {
|
|
746
|
+
this.appendLog(env.id, '[launcher] WebSocket connected');
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
750
|
+
try {
|
|
751
|
+
const data = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
|
|
752
|
+
this.handleWSMessage(env, data);
|
|
753
|
+
} catch {
|
|
754
|
+
/* ignore malformed messages */
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
ws.onerror = () => {
|
|
759
|
+
// Will trigger onclose
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
ws.onclose = () => {
|
|
763
|
+
this.wsConnections.delete(env.id);
|
|
764
|
+
// Auto-reconnect if still polling and env is active
|
|
765
|
+
if (this.wsEnabled && this.processes.has(env.id)) {
|
|
766
|
+
const timer = setTimeout(() => {
|
|
767
|
+
this.wsReconnectTimers.delete(env.id);
|
|
768
|
+
void this.connectWebSocket(env);
|
|
769
|
+
}, 3000);
|
|
770
|
+
this.wsReconnectTimers.set(env.id, timer);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
this.wsConnections.set(env.id, ws);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private disconnectWebSocket(envId: string): void {
|
|
778
|
+
const timer = this.wsReconnectTimers.get(envId);
|
|
779
|
+
if (timer) {
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
this.wsReconnectTimers.delete(envId);
|
|
782
|
+
}
|
|
783
|
+
const ws = this.wsConnections.get(envId);
|
|
784
|
+
if (ws) {
|
|
785
|
+
try {
|
|
786
|
+
ws.close();
|
|
787
|
+
} catch {
|
|
788
|
+
/* already closed */
|
|
789
|
+
}
|
|
790
|
+
this.wsConnections.delete(envId);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
795
|
+
private handleWSMessage(env: Environment, data: any): void {
|
|
796
|
+
// Parse uiResources from initial or delta messages
|
|
797
|
+
if (data.uiResources && Array.isArray(data.uiResources)) {
|
|
798
|
+
const incoming = (data.uiResources as Array<{ metadata?: { name?: string } }>)
|
|
799
|
+
.filter((item) => item.metadata?.name && item.metadata.name !== '(Tiltfile)')
|
|
800
|
+
.map((item) => this.parseUIResource(item, env));
|
|
801
|
+
|
|
802
|
+
if (incoming.length > 0) {
|
|
803
|
+
// Merge by name: update existing resources, add new ones, keep resources
|
|
804
|
+
// not present in this delta (WS deltas may only contain changed resources)
|
|
805
|
+
const existing = this.discoveredResources.get(env.id) ?? [];
|
|
806
|
+
const byName = new Map<string, CachedResource>();
|
|
807
|
+
for (const r of existing) byName.set(r.name, r);
|
|
808
|
+
for (const r of incoming) byName.set(r.name, r);
|
|
809
|
+
const merged = Array.from(byName.values());
|
|
810
|
+
|
|
811
|
+
this.discoveredResources.set(env.id, merged);
|
|
812
|
+
this.tiltPortReachable.set(env.id, true);
|
|
813
|
+
|
|
814
|
+
const selected = new Set(env.selectedResources ?? []);
|
|
815
|
+
// External envs auto-select all discovered resources (no manual discovery step)
|
|
816
|
+
if (env.external) {
|
|
817
|
+
env.selectedResources = merged.map((r) => r.name);
|
|
818
|
+
this.newResourceCount.set(env.id, 0);
|
|
819
|
+
this.onConfigMutated?.(this.config);
|
|
820
|
+
} else {
|
|
821
|
+
this.newResourceCount.set(env.id, merged.filter((r) => !selected.has(r.name)).length);
|
|
822
|
+
}
|
|
823
|
+
env.cachedResources = [...merged];
|
|
824
|
+
|
|
825
|
+
// Derive health from runtimeStatus (no need for HTTP probes when using WS)
|
|
826
|
+
for (const resource of incoming) {
|
|
827
|
+
const key = `${env.id}:${resource.name}`;
|
|
828
|
+
this.healthByKey.set(key, this.runtimeHealth(resource));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Parse structured logs from logList
|
|
834
|
+
if (data.logList?.segments && Array.isArray(data.logList.segments)) {
|
|
835
|
+
const spans = data.logList.spans as Record<string, { manifestName?: string }> | undefined;
|
|
836
|
+
for (const seg of data.logList.segments as Array<{ spanId?: string; text?: string }>) {
|
|
837
|
+
const text = seg.text?.trimEnd();
|
|
838
|
+
if (!text) continue;
|
|
839
|
+
|
|
840
|
+
// Map spanId → resource name via spans map
|
|
841
|
+
const manifestName = seg.spanId && spans?.[seg.spanId]?.manifestName;
|
|
842
|
+
if (manifestName && manifestName !== '(Tiltfile)') {
|
|
843
|
+
const key = `${env.id}:${manifestName}`;
|
|
844
|
+
const existing = this.resourceLogs.get(key) ?? [];
|
|
845
|
+
existing.push(text);
|
|
846
|
+
if (existing.length > this.maxLogLines) {
|
|
847
|
+
this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
|
|
848
|
+
} else {
|
|
849
|
+
this.resourceLogs.set(key, existing);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Also append to env-level logs
|
|
854
|
+
this.appendLog(env.id, text);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
this.emitStatus();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ─── Polling (fallback) ──────────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
private async pollTiltState(): Promise<void> {
|
|
864
|
+
for (const env of this.config.environments) {
|
|
865
|
+
// If WebSocket is connected, skip CLI polling for this env
|
|
866
|
+
const ws = this.wsConnections.get(env.id);
|
|
867
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
868
|
+
// Still probe health for services with endpoints (WS gives runtimeStatus-based health,
|
|
869
|
+
// but endpoint probing validates actual reachability)
|
|
870
|
+
const mergedByName = new Map<string, CachedResource>();
|
|
871
|
+
for (const resource of this.discoveredResources.get(env.id) ?? []) mergedByName.set(resource.name, resource);
|
|
872
|
+
for (const resourceName of env.selectedResources ?? []) {
|
|
873
|
+
const resource = mergedByName.get(resourceName);
|
|
874
|
+
if (!resource?.endpoint) continue;
|
|
875
|
+
const key = `${env.id}:${resource.name}`;
|
|
876
|
+
this.healthByKey.set(key, await this.computeHealth(resource, env));
|
|
877
|
+
}
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// No WebSocket — fall back to CLI polling
|
|
882
|
+
const tiltIsReachable =
|
|
883
|
+
(await this.tryConnect('127.0.0.1', env.tiltPort, '/')) || (await this.tryConnect('::1', env.tiltPort, '/'));
|
|
884
|
+
this.tiltPortReachable.set(env.id, tiltIsReachable);
|
|
885
|
+
|
|
886
|
+
// Try to establish WebSocket when we first detect the port is reachable
|
|
887
|
+
if (tiltIsReachable && !this.wsConnections.has(env.id) && this.wsEnabled) {
|
|
888
|
+
void this.connectWebSocket(env);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const resources = await this.readTiltResources(env);
|
|
892
|
+
if (resources) {
|
|
893
|
+
this.discoveredResources.set(env.id, resources);
|
|
894
|
+
const selected = new Set(env.selectedResources ?? []);
|
|
895
|
+
// External envs auto-select all discovered resources (no manual discovery step)
|
|
896
|
+
if (env.external) {
|
|
897
|
+
env.selectedResources = resources.map((r) => r.name);
|
|
898
|
+
this.newResourceCount.set(env.id, 0);
|
|
899
|
+
this.onConfigMutated?.(this.config);
|
|
900
|
+
} else {
|
|
901
|
+
this.newResourceCount.set(env.id, resources.filter((resource) => !selected.has(resource.name)).length);
|
|
902
|
+
}
|
|
903
|
+
env.cachedResources = [...resources];
|
|
904
|
+
|
|
905
|
+
// Start per-resource log streams for running envs (only if no WS)
|
|
906
|
+
if (tiltIsReachable && !this.wsConnections.has(env.id)) {
|
|
907
|
+
this.startResourceLogStreams(env);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Always probe selected services from merged cached/discovered data
|
|
912
|
+
const mergedByName = new Map<string, CachedResource>();
|
|
913
|
+
for (const resource of env.cachedResources ?? []) mergedByName.set(resource.name, resource);
|
|
914
|
+
for (const resource of this.discoveredResources.get(env.id) ?? []) mergedByName.set(resource.name, resource);
|
|
915
|
+
for (const resourceName of env.selectedResources ?? []) {
|
|
916
|
+
const resource = mergedByName.get(resourceName);
|
|
917
|
+
if (!resource) continue;
|
|
918
|
+
const key = `${env.id}:${resource.name}`;
|
|
919
|
+
this.healthByKey.set(key, await this.computeHealth(resource, env));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
this.emitStatus();
|
|
923
|
+
}
|
|
924
|
+
}
|