@panorama-ai/gateway 2.24.133 → 2.24.137
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/dist/index.js +568 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ const DEFAULT_GATEWAY_CONCURRENCY = 10;
|
|
|
28
28
|
const MAX_SUBAGENT_OUTPUT_BYTES = 5_000_000;
|
|
29
29
|
const MAX_SUBAGENT_EVENT_PAYLOAD_BYTES = 200_000;
|
|
30
30
|
const MAX_GATEWAY_LOG_BYTES = 200_000;
|
|
31
|
+
const MAX_GATEWAY_EVENT_MESSAGE_CHARS = 2000;
|
|
31
32
|
const SUBAGENT_EVENT_BATCH_SIZE = 200;
|
|
32
33
|
const SUBAGENT_CANCEL_KILL_TIMEOUT_MS = 5_000;
|
|
33
34
|
const PROCESS_KILL_GRACE_MS = 2_000;
|
|
@@ -83,6 +84,11 @@ const GATEWAY_CONCURRENCY = (() => {
|
|
|
83
84
|
const activeSubagentRuns = new Map();
|
|
84
85
|
const pendingCancelByRunId = new Map();
|
|
85
86
|
const pendingCancelBySubagentId = new Map();
|
|
87
|
+
const pendingGatewayEvents = [];
|
|
88
|
+
let CURRENT_CAPABILITIES = null;
|
|
89
|
+
let CURRENT_DEVICE_NAME = null;
|
|
90
|
+
let CURRENT_CONFIG = null;
|
|
91
|
+
let CURRENT_PROVIDER_HEALTH = null;
|
|
86
92
|
let ACTIVE_OPTIONS = null;
|
|
87
93
|
function parseArgs(argv) {
|
|
88
94
|
const positional = [];
|
|
@@ -121,7 +127,7 @@ function parseArgs(argv) {
|
|
|
121
127
|
return { command, positional, options };
|
|
122
128
|
}
|
|
123
129
|
function printHelp() {
|
|
124
|
-
console.log(`\nPanorama Gateway\n\nUsage:\n panorama-gateway pair <PAIRING_CODE> [--device-name \"My Mac\"] [--supabase-url URL] [--anon-key KEY]\n panorama-gateway start [--device-name \"My Mac\"] [--foreground|--daemon]\n panorama-gateway stop\n panorama-gateway status\n panorama-gateway logs [--lines 200] [--no-follow]\n\nNotes:\n Start runs in the background by default for the built CLI. Use --foreground to keep it attached.\n\nOutput options:\n --verbose, -v Show technical details (paths, IDs, PIDs)\n\nEnvironment options:\n --env <local|dev|test|stage|prod> Load .env.<env> from repo root (defaults to .env)\n --env-file <path> Load a specific env file\n PANORAMA_ENV Same as --env\n PANORAMA_ENV_FILE Same as --env-file\n\nCLI overrides:\n --config-dir <path> Override config directory (default: ~/.panorama/gateway)\n --config-path <path> Override gateway config file\n --log-path <path> Override gateway log file\n --pid-path <path> Override gateway pid file\n --claude-cli <path> Override Claude CLI command\n --codex-cli <path> Override Codex CLI command\n --gemini-cli <path> Override Gemini CLI command\n --claude-home <path> Override Claude home directory\n\nEnvironment overrides:\n PANORAMA_SUPABASE_URL or SUPABASE_URL\n PANORAMA_SUPABASE_ANON_KEY or SUPABASE_ANON_KEY or SUPABASE_PUBLISHABLE_KEY\n PANORAMA_GATEWAY_CONFIG_DIR\n PANORAMA_GATEWAY_CONFIG_PATH\n PANORAMA_GATEWAY_LOG_PATH\n PANORAMA_GATEWAY_PID_PATH\n PANORAMA_CLAUDE_CLI or CLAUDE_CLI\n PANORAMA_CODEX_CLI or CODEX_CLI\n PANORAMA_GEMINI_CLI or GEMINI_CLI\n PANORAMA_CLAUDE_HOME\n`);
|
|
130
|
+
console.log(`\nPanorama Gateway\n\nUsage:\n panorama-gateway pair <PAIRING_CODE> [--device-name \"My Mac\"] [--supabase-url URL] [--anon-key KEY]\n panorama-gateway start [--device-name \"My Mac\"] [--foreground|--daemon]\n panorama-gateway stop\n panorama-gateway status\n panorama-gateway logs [--lines 200] [--no-follow]\n panorama-gateway doctor\n\nNotes:\n Start runs in the background by default for the built CLI. Use --foreground to keep it attached.\n\nOutput options:\n --verbose, -v Show technical details (paths, IDs, PIDs)\n\nEnvironment options:\n --env <local|dev|test|stage|prod> Load .env.<env> from repo root (defaults to .env)\n --env-file <path> Load a specific env file\n PANORAMA_ENV Same as --env\n PANORAMA_ENV_FILE Same as --env-file\n\nCLI overrides:\n --config-dir <path> Override config directory (default: ~/.panorama/gateway)\n --config-path <path> Override gateway config file\n --log-path <path> Override gateway log file\n --pid-path <path> Override gateway pid file\n --claude-cli <path> Override Claude CLI command\n --codex-cli <path> Override Codex CLI command\n --gemini-cli <path> Override Gemini CLI command\n --claude-home <path> Override Claude home directory\n\nEnvironment overrides:\n PANORAMA_SUPABASE_URL or SUPABASE_URL\n PANORAMA_SUPABASE_ANON_KEY or SUPABASE_ANON_KEY or SUPABASE_PUBLISHABLE_KEY\n PANORAMA_GATEWAY_CONFIG_DIR\n PANORAMA_GATEWAY_CONFIG_PATH\n PANORAMA_GATEWAY_LOG_PATH\n PANORAMA_GATEWAY_PID_PATH\n PANORAMA_CLAUDE_CLI or CLAUDE_CLI\n PANORAMA_CODEX_CLI or CODEX_CLI\n PANORAMA_GEMINI_CLI or GEMINI_CLI\n PANORAMA_CLAUDE_HOME\n`);
|
|
125
131
|
}
|
|
126
132
|
function getStringOption(options, key) {
|
|
127
133
|
const value = options[key];
|
|
@@ -177,6 +183,83 @@ function resolveGatewayPaths(options) {
|
|
|
177
183
|
}
|
|
178
184
|
return paths;
|
|
179
185
|
}
|
|
186
|
+
function resolveGatewayFallbackConfigDir() {
|
|
187
|
+
if (process.platform === 'darwin') {
|
|
188
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Panorama Gateway');
|
|
189
|
+
}
|
|
190
|
+
if (process.platform === 'win32') {
|
|
191
|
+
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
192
|
+
return path.join(base, 'Panorama Gateway');
|
|
193
|
+
}
|
|
194
|
+
const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
195
|
+
return path.join(dataHome, 'panorama-gateway');
|
|
196
|
+
}
|
|
197
|
+
async function ensureWritableDirectory(dir) {
|
|
198
|
+
try {
|
|
199
|
+
await fs.mkdir(dir, { recursive: true });
|
|
200
|
+
const probe = path.join(dir, `.panorama-write-${randomUUID()}`);
|
|
201
|
+
await fs.writeFile(probe, 'ok', 'utf-8');
|
|
202
|
+
await fs.unlink(probe);
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function prepareGatewayConfigDir(options, allowFallback) {
|
|
210
|
+
const resolvedOptions = getActiveOptions(options);
|
|
211
|
+
const overrideDir = getStringOption(resolvedOptions, 'config-dir') || process.env.PANORAMA_GATEWAY_CONFIG_DIR;
|
|
212
|
+
const overridePath = getStringOption(resolvedOptions, 'config-path') || process.env.PANORAMA_GATEWAY_CONFIG_PATH;
|
|
213
|
+
const explicitOverride = Boolean(overrideDir || overridePath);
|
|
214
|
+
const { configDir, configPath, logPath, pidPath } = resolveGatewayPaths(resolvedOptions);
|
|
215
|
+
const writable = await ensureWritableDirectory(configDir);
|
|
216
|
+
if (writable.ok) {
|
|
217
|
+
process.env.PANORAMA_GATEWAY_CONFIG_DIR = configDir;
|
|
218
|
+
process.env.PANORAMA_GATEWAY_CONFIG_PATH = configPath;
|
|
219
|
+
process.env.PANORAMA_GATEWAY_LOG_PATH = logPath;
|
|
220
|
+
process.env.PANORAMA_GATEWAY_PID_PATH = pidPath;
|
|
221
|
+
return { configDir, configPath };
|
|
222
|
+
}
|
|
223
|
+
if (explicitOverride || !allowFallback) {
|
|
224
|
+
throw new GatewayCliError('Gateway config directory is not writable.', writable.error ??
|
|
225
|
+
`Unable to write to ${configDir}. Set --config-dir or PANORAMA_GATEWAY_CONFIG_DIR to a writable location.`);
|
|
226
|
+
}
|
|
227
|
+
const fallbackDir = resolveGatewayFallbackConfigDir();
|
|
228
|
+
const fallbackWritable = await ensureWritableDirectory(fallbackDir);
|
|
229
|
+
if (!fallbackWritable.ok) {
|
|
230
|
+
throw new GatewayCliError('Gateway config directory is not writable.', fallbackWritable.error ??
|
|
231
|
+
`Unable to write to ${configDir} or fallback ${fallbackDir}.`);
|
|
232
|
+
}
|
|
233
|
+
const fallbackOptions = { ...resolvedOptions, 'config-dir': fallbackDir };
|
|
234
|
+
const fallbackPaths = resolveGatewayPaths(fallbackOptions);
|
|
235
|
+
if (configPath !== fallbackPaths.configPath && fsSync.existsSync(configPath)) {
|
|
236
|
+
try {
|
|
237
|
+
await fs.mkdir(path.dirname(fallbackPaths.configPath), { recursive: true });
|
|
238
|
+
await fs.copyFile(configPath, fallbackPaths.configPath);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
logError('Failed to migrate gateway config to fallback directory', {
|
|
242
|
+
from: configPath,
|
|
243
|
+
to: fallbackPaths.configPath,
|
|
244
|
+
error: error instanceof Error ? error.message : String(error),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
resolvedOptions['config-dir'] = fallbackDir;
|
|
249
|
+
process.env.PANORAMA_GATEWAY_CONFIG_DIR = fallbackDir;
|
|
250
|
+
process.env.PANORAMA_GATEWAY_CONFIG_PATH = fallbackPaths.configPath;
|
|
251
|
+
process.env.PANORAMA_GATEWAY_LOG_PATH = fallbackPaths.logPath;
|
|
252
|
+
process.env.PANORAMA_GATEWAY_PID_PATH = fallbackPaths.pidPath;
|
|
253
|
+
cliWarn('Gateway config directory not writable; using fallback location.', resolvedOptions, [
|
|
254
|
+
{ label: 'Fallback', value: formatPathForDisplay(fallbackDir), verboseOnly: true },
|
|
255
|
+
{ label: 'Previous', value: formatPathForDisplay(configDir), verboseOnly: true },
|
|
256
|
+
]);
|
|
257
|
+
return {
|
|
258
|
+
configDir: fallbackPaths.configDir,
|
|
259
|
+
configPath: fallbackPaths.configPath,
|
|
260
|
+
migratedFrom: configDir,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
180
263
|
function resolveSubagentWorkdirRoot(options) {
|
|
181
264
|
const { configDir } = resolveGatewayPaths(options);
|
|
182
265
|
return resolveSubagentWorkdirRootBase(configDir);
|
|
@@ -209,6 +292,71 @@ function formatPathForDisplay(value) {
|
|
|
209
292
|
}
|
|
210
293
|
return value;
|
|
211
294
|
}
|
|
295
|
+
function truncateText(value, maxLength) {
|
|
296
|
+
if (value.length <= maxLength)
|
|
297
|
+
return value;
|
|
298
|
+
return `${value.slice(0, maxLength)}...`;
|
|
299
|
+
}
|
|
300
|
+
async function emitGatewayEvent(supabase, config, payload) {
|
|
301
|
+
if (!config.gatewayId || !config.teamId)
|
|
302
|
+
return;
|
|
303
|
+
const message = truncateText(payload.message, MAX_GATEWAY_EVENT_MESSAGE_CHARS);
|
|
304
|
+
const details = payload.details ?? {};
|
|
305
|
+
const { error } = await supabase
|
|
306
|
+
.from('gateway_events')
|
|
307
|
+
.insert({
|
|
308
|
+
gateway_id: config.gatewayId,
|
|
309
|
+
team_id: config.teamId,
|
|
310
|
+
provider: payload.provider ?? null,
|
|
311
|
+
level: payload.level,
|
|
312
|
+
code: payload.code ?? null,
|
|
313
|
+
message,
|
|
314
|
+
details,
|
|
315
|
+
});
|
|
316
|
+
if (error) {
|
|
317
|
+
logError('Failed to record gateway event', {
|
|
318
|
+
error: error.message,
|
|
319
|
+
code: payload.code ?? null,
|
|
320
|
+
provider: payload.provider ?? null,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function queueGatewayEvent(payload) {
|
|
325
|
+
pendingGatewayEvents.push(payload);
|
|
326
|
+
}
|
|
327
|
+
async function flushGatewayEvents(supabase, config) {
|
|
328
|
+
if (pendingGatewayEvents.length === 0)
|
|
329
|
+
return;
|
|
330
|
+
const queued = pendingGatewayEvents.splice(0, pendingGatewayEvents.length);
|
|
331
|
+
for (const payload of queued) {
|
|
332
|
+
await emitGatewayEvent(supabase, config, payload);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function emitProviderHealthEvents(supabase, config, providerHealth, context) {
|
|
336
|
+
const entries = Object.entries(providerHealth);
|
|
337
|
+
for (const [providerId, health] of entries) {
|
|
338
|
+
if (health.status === 'healthy')
|
|
339
|
+
continue;
|
|
340
|
+
const error = typeof health.error === 'string' ? health.error : undefined;
|
|
341
|
+
const errorSummary = error ? truncateText(error, 500) : undefined;
|
|
342
|
+
const message = errorSummary
|
|
343
|
+
? `${providerId} validation failed: ${errorSummary}`
|
|
344
|
+
: `${providerId} validation status: ${health.status}`;
|
|
345
|
+
await emitGatewayEvent(supabase, config, {
|
|
346
|
+
level: health.status === 'unhealthy' ? 'error' : 'warn',
|
|
347
|
+
code: 'provider_validation',
|
|
348
|
+
provider: providerId,
|
|
349
|
+
message,
|
|
350
|
+
details: {
|
|
351
|
+
context,
|
|
352
|
+
status: health.status,
|
|
353
|
+
check_type: health.check_type,
|
|
354
|
+
error: errorSummary ?? null,
|
|
355
|
+
duration_ms: health.duration_ms ?? null,
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
212
360
|
function writeCliDetails(details, options, writer) {
|
|
213
361
|
if (!details || details.length === 0)
|
|
214
362
|
return;
|
|
@@ -475,6 +623,28 @@ function logError(message, data) {
|
|
|
475
623
|
console.error(`[gateway] ${message}`);
|
|
476
624
|
}
|
|
477
625
|
}
|
|
626
|
+
async function createGatewayEventClient(config) {
|
|
627
|
+
if (!config.supabaseUrl || !config.supabaseAnonKey)
|
|
628
|
+
return null;
|
|
629
|
+
const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
630
|
+
auth: {
|
|
631
|
+
persistSession: false,
|
|
632
|
+
autoRefreshToken: false,
|
|
633
|
+
detectSessionInUrl: false,
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
|
|
637
|
+
access_token: config.accessToken,
|
|
638
|
+
refresh_token: config.refreshToken,
|
|
639
|
+
});
|
|
640
|
+
if (sessionError || !sessionData?.session) {
|
|
641
|
+
logError('Failed to authenticate gateway event session', {
|
|
642
|
+
error: sessionError?.message || 'No session returned',
|
|
643
|
+
});
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
return supabase;
|
|
647
|
+
}
|
|
478
648
|
function readPidFile(options) {
|
|
479
649
|
const { pidPath } = resolveGatewayPaths(options);
|
|
480
650
|
try {
|
|
@@ -517,7 +687,7 @@ async function safeJson(response) {
|
|
|
517
687
|
return null;
|
|
518
688
|
}
|
|
519
689
|
}
|
|
520
|
-
async function pairGateway(code, options) {
|
|
690
|
+
async function pairGateway(code, options, configResolution) {
|
|
521
691
|
const supabaseUrl = resolveSupabaseUrl(options);
|
|
522
692
|
const supabaseAnonKey = resolveSupabaseAnonKey(options);
|
|
523
693
|
if (!supabaseUrl || !supabaseAnonKey) {
|
|
@@ -557,12 +727,14 @@ async function pairGateway(code, options) {
|
|
|
557
727
|
await saveConfig(config, options);
|
|
558
728
|
let providerHealthSummary = null;
|
|
559
729
|
let anyProviderReady = false;
|
|
730
|
+
let providerHealth = null;
|
|
560
731
|
try {
|
|
561
732
|
await ensureClaudeGatewayHome();
|
|
562
|
-
const { providerHealth, anyProviderReady: ready } = await buildCapabilities({
|
|
733
|
+
const { providerHealth: resolvedHealth, anyProviderReady: ready } = await buildCapabilities({
|
|
563
734
|
validationMode: 'full',
|
|
564
735
|
});
|
|
565
|
-
config.providerHealth =
|
|
736
|
+
config.providerHealth = resolvedHealth;
|
|
737
|
+
providerHealth = resolvedHealth;
|
|
566
738
|
await saveConfig(config, options);
|
|
567
739
|
providerHealthSummary = Object.entries(providerHealth)
|
|
568
740
|
.map(([id, health]) => `${id}:${health.status}`)
|
|
@@ -587,6 +759,24 @@ async function pairGateway(code, options) {
|
|
|
587
759
|
if (providerHealthSummary && !anyProviderReady) {
|
|
588
760
|
cliWarn('No gateway providers passed validation. Install/authenticate a provider before starting the gateway.', options, [{ label: 'Provider health', value: providerHealthSummary, verboseOnly: true }]);
|
|
589
761
|
}
|
|
762
|
+
const eventClient = await createGatewayEventClient(config);
|
|
763
|
+
if (eventClient) {
|
|
764
|
+
await flushGatewayEvents(eventClient, config);
|
|
765
|
+
if (configResolution?.migratedFrom) {
|
|
766
|
+
await emitGatewayEvent(eventClient, config, {
|
|
767
|
+
level: 'warn',
|
|
768
|
+
code: 'config_dir_fallback',
|
|
769
|
+
message: 'Gateway config directory was not writable; using fallback location.',
|
|
770
|
+
details: {
|
|
771
|
+
previous: configResolution.migratedFrom,
|
|
772
|
+
current: configResolution.configDir,
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (providerHealth) {
|
|
777
|
+
await emitProviderHealthEvents(eventClient, config, providerHealth, 'pairing');
|
|
778
|
+
}
|
|
779
|
+
}
|
|
590
780
|
}
|
|
591
781
|
async function execClaudeVersion() {
|
|
592
782
|
const start = Date.now();
|
|
@@ -665,54 +855,110 @@ function resolveRunPlanEnv(env, workDir) {
|
|
|
665
855
|
return [key, value];
|
|
666
856
|
}));
|
|
667
857
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
858
|
+
function isCodexConfigError(message) {
|
|
859
|
+
if (!message)
|
|
860
|
+
return false;
|
|
861
|
+
const normalized = message.toLowerCase();
|
|
862
|
+
return (normalized.includes('config.toml') ||
|
|
863
|
+
normalized.includes('mcp_servers') ||
|
|
864
|
+
normalized.includes('invalid transport'));
|
|
865
|
+
}
|
|
866
|
+
async function resetCodexConfig() {
|
|
867
|
+
const home = resolveGatewayProviderHome('codex');
|
|
868
|
+
const candidates = [
|
|
869
|
+
path.join(home, '.codex', 'config.toml'),
|
|
870
|
+
path.join(home, 'xdg', 'config', 'codex', 'config.toml'),
|
|
871
|
+
];
|
|
872
|
+
const resetPaths = [];
|
|
674
873
|
try {
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
assertSafeOutputPath(workDir, runPlan.outputPath);
|
|
682
|
-
await writeRunPlanFiles(workDir, runPlan.files);
|
|
683
|
-
const runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
|
|
684
|
-
timeoutMs: runPlan.timeoutMs ?? PROVIDER_VALIDATION_TIMEOUT_MS,
|
|
685
|
-
cwd: workDir,
|
|
686
|
-
env: resolveRunPlanEnv(runPlan.env, workDir),
|
|
687
|
-
});
|
|
688
|
-
if (!runResult.ok) {
|
|
689
|
-
return {
|
|
690
|
-
ok: false,
|
|
691
|
-
durationMs: runResult.durationMs,
|
|
692
|
-
error: runResult.error || runResult.stderr || 'Provider validation failed',
|
|
693
|
-
};
|
|
874
|
+
for (const configPath of candidates) {
|
|
875
|
+
if (!fsSync.existsSync(configPath))
|
|
876
|
+
continue;
|
|
877
|
+
const backupPath = `${configPath}.backup.${Date.now()}`;
|
|
878
|
+
await fs.rename(configPath, backupPath);
|
|
879
|
+
resetPaths.push(configPath);
|
|
694
880
|
}
|
|
881
|
+
return { reset: resetPaths.length > 0, paths: resetPaths };
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
return {
|
|
885
|
+
reset: resetPaths.length > 0,
|
|
886
|
+
paths: resetPaths,
|
|
887
|
+
error: error instanceof Error ? error.message : String(error),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async function runProviderValidation(provider, model) {
|
|
892
|
+
const attempt = async () => {
|
|
893
|
+
const workRoot = resolveValidationWorkdirRoot();
|
|
894
|
+
const workDir = await createWorkDir(workRoot, [
|
|
895
|
+
{ value: provider.id, label: 'provider id' },
|
|
896
|
+
{ value: randomUUID(), label: 'validation run id' },
|
|
897
|
+
]);
|
|
695
898
|
try {
|
|
696
|
-
provider.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
workDir,
|
|
899
|
+
const runPlan = provider.buildModelRunPlan({
|
|
900
|
+
model,
|
|
901
|
+
prompt: PROVIDER_VALIDATION_PROMPT,
|
|
902
|
+
jsonSchema: PROVIDER_VALIDATION_SCHEMA,
|
|
903
|
+
timeoutMs: PROVIDER_VALIDATION_TIMEOUT_MS,
|
|
702
904
|
});
|
|
905
|
+
assertSafeOutputPath(workDir, runPlan.outputPath);
|
|
906
|
+
await writeRunPlanFiles(workDir, runPlan.files);
|
|
907
|
+
const runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
|
|
908
|
+
timeoutMs: runPlan.timeoutMs ?? PROVIDER_VALIDATION_TIMEOUT_MS,
|
|
909
|
+
cwd: workDir,
|
|
910
|
+
env: resolveRunPlanEnv(runPlan.env, workDir),
|
|
911
|
+
});
|
|
912
|
+
if (!runResult.ok) {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
durationMs: runResult.durationMs,
|
|
916
|
+
error: runResult.error || runResult.stderr || 'Provider validation failed',
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
provider.normalizeModelRunResult({
|
|
921
|
+
stdout: runResult.stdout,
|
|
922
|
+
outputFormat: runPlan.outputFormat,
|
|
923
|
+
outputMode: runPlan.outputMode,
|
|
924
|
+
outputPath: runPlan.outputPath,
|
|
925
|
+
workDir,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
durationMs: runResult.durationMs,
|
|
932
|
+
error: error instanceof Error ? error.message : 'Failed to parse validation output',
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return { ok: true, durationMs: runResult.durationMs };
|
|
703
936
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
937
|
+
finally {
|
|
938
|
+
await cleanupWorkDir(workDir, workRoot);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
const result = await attempt();
|
|
942
|
+
if (result.ok)
|
|
943
|
+
return result;
|
|
944
|
+
if (provider.id === 'codex' && isCodexConfigError(result.error)) {
|
|
945
|
+
const reset = await resetCodexConfig();
|
|
946
|
+
if (reset.reset) {
|
|
947
|
+
logInfo('Reset Codex config after validation error', { paths: reset.paths });
|
|
948
|
+
queueGatewayEvent({
|
|
949
|
+
level: 'warn',
|
|
950
|
+
code: 'provider_config_reset',
|
|
951
|
+
provider: provider.id,
|
|
952
|
+
message: 'Codex config reset after validation error.',
|
|
953
|
+
details: {
|
|
954
|
+
paths: reset.paths,
|
|
955
|
+
error: reset.error ?? null,
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
return await attempt();
|
|
710
959
|
}
|
|
711
|
-
return { ok: true, durationMs: runResult.durationMs };
|
|
712
|
-
}
|
|
713
|
-
finally {
|
|
714
|
-
await cleanupWorkDir(workDir, workRoot);
|
|
715
960
|
}
|
|
961
|
+
return result;
|
|
716
962
|
}
|
|
717
963
|
function coerceHealthStatus(value) {
|
|
718
964
|
if (value === 'healthy' || value === 'unhealthy' || value === 'unknown' || value === 'unavailable') {
|
|
@@ -751,8 +997,10 @@ async function resolveProviderHealth(params) {
|
|
|
751
997
|
duration_ms: validation.durationMs,
|
|
752
998
|
};
|
|
753
999
|
}
|
|
1000
|
+
const fallbackStatus = coerceHealthStatus(lastFullStatus);
|
|
1001
|
+
const previousStatus = previous?.status ? coerceHealthStatus(previous.status) : undefined;
|
|
754
1002
|
return {
|
|
755
|
-
status:
|
|
1003
|
+
status: previousStatus ?? fallbackStatus,
|
|
756
1004
|
check_type: 'light',
|
|
757
1005
|
checked_at: now,
|
|
758
1006
|
last_full_checked_at: lastFullCheckedAt,
|
|
@@ -853,6 +1101,129 @@ function appendClaudeIsolationArgs(args, support) {
|
|
|
853
1101
|
args.push('--disable-slash-commands');
|
|
854
1102
|
}
|
|
855
1103
|
}
|
|
1104
|
+
function formatProviderLabel(providerId) {
|
|
1105
|
+
switch (providerId) {
|
|
1106
|
+
case 'claude_code':
|
|
1107
|
+
return 'Claude';
|
|
1108
|
+
case 'gemini':
|
|
1109
|
+
return 'Gemini';
|
|
1110
|
+
case 'codex':
|
|
1111
|
+
return 'Codex';
|
|
1112
|
+
default:
|
|
1113
|
+
return providerId;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function normalizeProviderErrorSummary(message) {
|
|
1117
|
+
if (!message)
|
|
1118
|
+
return undefined;
|
|
1119
|
+
const normalized = message.replace(/\s+/g, ' ').trim();
|
|
1120
|
+
if (!normalized)
|
|
1121
|
+
return undefined;
|
|
1122
|
+
return truncateText(normalized, 500);
|
|
1123
|
+
}
|
|
1124
|
+
function buildProviderFailureMessage(providerId, detail) {
|
|
1125
|
+
const label = formatProviderLabel(providerId);
|
|
1126
|
+
const detailSuffix = detail ? ` Details: ${detail}` : '';
|
|
1127
|
+
return `Gateway provider ${label} is unhealthy. Try another provider or run "panorama-gateway doctor".${detailSuffix}`;
|
|
1128
|
+
}
|
|
1129
|
+
function isProviderReady(entry) {
|
|
1130
|
+
if (!entry)
|
|
1131
|
+
return false;
|
|
1132
|
+
if (entry.health?.status === 'healthy')
|
|
1133
|
+
return true;
|
|
1134
|
+
if (!entry.available)
|
|
1135
|
+
return false;
|
|
1136
|
+
if (!entry.health)
|
|
1137
|
+
return true;
|
|
1138
|
+
return entry.health.status !== 'unavailable' && entry.health.status !== 'unhealthy';
|
|
1139
|
+
}
|
|
1140
|
+
function computeGatewayStatusFromCapabilities(providers) {
|
|
1141
|
+
const anyReady = Object.values(providers).some((entry) => isProviderReady(entry));
|
|
1142
|
+
return anyReady ? 'ready' : 'error';
|
|
1143
|
+
}
|
|
1144
|
+
function resolveRuntimeProviderHealth(providerId) {
|
|
1145
|
+
if (CURRENT_PROVIDER_HEALTH && CURRENT_PROVIDER_HEALTH[providerId]) {
|
|
1146
|
+
return CURRENT_PROVIDER_HEALTH[providerId];
|
|
1147
|
+
}
|
|
1148
|
+
const fromConfig = CURRENT_CONFIG?.providerHealth?.[providerId];
|
|
1149
|
+
if (fromConfig)
|
|
1150
|
+
return fromConfig;
|
|
1151
|
+
return PROVIDER_CAPABILITIES?.[providerId]?.health;
|
|
1152
|
+
}
|
|
1153
|
+
async function updateRuntimeProviderHealth(params) {
|
|
1154
|
+
const providerCapabilities = PROVIDER_CAPABILITIES;
|
|
1155
|
+
if (!providerCapabilities || !providerCapabilities[params.providerId])
|
|
1156
|
+
return;
|
|
1157
|
+
const previous = resolveRuntimeProviderHealth(params.providerId);
|
|
1158
|
+
const nextStatus = params.status;
|
|
1159
|
+
const errorSummary = normalizeProviderErrorSummary(params.error);
|
|
1160
|
+
const nowIso = new Date().toISOString();
|
|
1161
|
+
const nextHealth = {
|
|
1162
|
+
status: nextStatus,
|
|
1163
|
+
check_type: 'light',
|
|
1164
|
+
checked_at: nowIso,
|
|
1165
|
+
last_full_checked_at: previous?.last_full_checked_at,
|
|
1166
|
+
last_full_status: previous?.last_full_status,
|
|
1167
|
+
error: nextStatus === 'healthy' ? undefined : errorSummary ?? previous?.error,
|
|
1168
|
+
};
|
|
1169
|
+
providerCapabilities[params.providerId] = {
|
|
1170
|
+
...providerCapabilities[params.providerId],
|
|
1171
|
+
health: nextHealth,
|
|
1172
|
+
};
|
|
1173
|
+
if (CURRENT_CAPABILITIES) {
|
|
1174
|
+
const rawProviders = CURRENT_CAPABILITIES.providers;
|
|
1175
|
+
if (rawProviders && typeof rawProviders === 'object') {
|
|
1176
|
+
const providers = rawProviders;
|
|
1177
|
+
if (providers[params.providerId]) {
|
|
1178
|
+
providers[params.providerId] = {
|
|
1179
|
+
...providers[params.providerId],
|
|
1180
|
+
health: nextHealth,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
CURRENT_CAPABILITIES = {
|
|
1184
|
+
...CURRENT_CAPABILITIES,
|
|
1185
|
+
providers,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (CURRENT_CONFIG) {
|
|
1190
|
+
if (!CURRENT_CONFIG.providerHealth) {
|
|
1191
|
+
CURRENT_CONFIG.providerHealth = {};
|
|
1192
|
+
}
|
|
1193
|
+
CURRENT_CONFIG.providerHealth[params.providerId] = nextHealth;
|
|
1194
|
+
void saveConfig(CURRENT_CONFIG).catch((error) => {
|
|
1195
|
+
logError('Failed to persist gateway provider health', {
|
|
1196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1197
|
+
provider: params.providerId,
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
CURRENT_PROVIDER_HEALTH = {
|
|
1202
|
+
...(CURRENT_PROVIDER_HEALTH ?? {}),
|
|
1203
|
+
[params.providerId]: nextHealth,
|
|
1204
|
+
};
|
|
1205
|
+
const nextStatusOverall = computeGatewayStatusFromCapabilities(providerCapabilities);
|
|
1206
|
+
if (CURRENT_CAPABILITIES && CURRENT_DEVICE_NAME) {
|
|
1207
|
+
await sendHeartbeat(params.supabase, nextStatusOverall, CURRENT_CAPABILITIES, CURRENT_DEVICE_NAME);
|
|
1208
|
+
}
|
|
1209
|
+
const statusChanged = previous?.status !== nextStatus;
|
|
1210
|
+
if (statusChanged && nextStatus !== 'healthy' && CURRENT_CONFIG) {
|
|
1211
|
+
const label = formatProviderLabel(params.providerId);
|
|
1212
|
+
await emitGatewayEvent(params.supabase, CURRENT_CONFIG, {
|
|
1213
|
+
level: nextStatus === 'unhealthy' ? 'error' : 'warn',
|
|
1214
|
+
code: 'provider_unhealthy',
|
|
1215
|
+
provider: params.providerId,
|
|
1216
|
+
message: `${label} provider marked ${nextStatus}.`,
|
|
1217
|
+
details: {
|
|
1218
|
+
context: params.context,
|
|
1219
|
+
status: nextStatus,
|
|
1220
|
+
error: errorSummary ?? null,
|
|
1221
|
+
job_id: params.jobId ?? null,
|
|
1222
|
+
run_id: params.runId ?? null,
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
856
1227
|
async function ensureClaudeGatewayHome() {
|
|
857
1228
|
const claudeHome = resolveClaudeGatewayHome();
|
|
858
1229
|
await fs.mkdir(claudeHome, { recursive: true });
|
|
@@ -2065,9 +2436,38 @@ async function handleSubagentRunJob(supabase, job) {
|
|
|
2065
2436
|
const exitCodeMessage = activeResult.exitCode === null
|
|
2066
2437
|
? 'Subagent process did not exit cleanly'
|
|
2067
2438
|
: `Subagent exited with code ${activeResult.exitCode}`;
|
|
2068
|
-
|
|
2439
|
+
let errorMessage = !activeResult.ok
|
|
2069
2440
|
? (activeResult.error ?? stderrMessage ?? exitCodeMessage)
|
|
2070
2441
|
: parseError ?? null;
|
|
2442
|
+
const providerId = adapter.id;
|
|
2443
|
+
if (!wasCancelled) {
|
|
2444
|
+
if (shouldFail) {
|
|
2445
|
+
const parsed = activeResult.stderr
|
|
2446
|
+
? extractGatewayErrorSummary(activeResult.stderr)?.message
|
|
2447
|
+
: null;
|
|
2448
|
+
const providerSummary = normalizeProviderErrorSummary(parsed ?? errorMessage ?? null);
|
|
2449
|
+
await updateRuntimeProviderHealth({
|
|
2450
|
+
supabase,
|
|
2451
|
+
providerId,
|
|
2452
|
+
status: 'unhealthy',
|
|
2453
|
+
error: providerSummary ?? errorMessage,
|
|
2454
|
+
context: 'subagent_run',
|
|
2455
|
+
jobId: job.id,
|
|
2456
|
+
runId: resolvedRunId,
|
|
2457
|
+
});
|
|
2458
|
+
errorMessage = buildProviderFailureMessage(providerId, providerSummary ?? errorMessage);
|
|
2459
|
+
}
|
|
2460
|
+
else {
|
|
2461
|
+
await updateRuntimeProviderHealth({
|
|
2462
|
+
supabase,
|
|
2463
|
+
providerId,
|
|
2464
|
+
status: 'healthy',
|
|
2465
|
+
context: 'subagent_run',
|
|
2466
|
+
jobId: job.id,
|
|
2467
|
+
runId: resolvedRunId,
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2071
2471
|
const failureOutput = buildErrorOutput(wasCancelled ? cancelReason : errorMessage ?? 'Subagent run failed');
|
|
2072
2472
|
const metadataUpdate = {
|
|
2073
2473
|
schema_version: SUBAGENT_SCHEMA_VERSION,
|
|
@@ -2197,6 +2597,16 @@ async function handleModelRunJob(_supabase, job) {
|
|
|
2197
2597
|
const providerId = (providerFromPayload === 'claude_code' || providerFromPayload === 'codex' || providerFromPayload === 'gemini')
|
|
2198
2598
|
? providerFromPayload
|
|
2199
2599
|
: inferGatewayProviderFromModel(model);
|
|
2600
|
+
const updateProviderHealth = async (status, errorMessage) => {
|
|
2601
|
+
await updateRuntimeProviderHealth({
|
|
2602
|
+
supabase: _supabase,
|
|
2603
|
+
providerId,
|
|
2604
|
+
status,
|
|
2605
|
+
error: errorMessage ?? null,
|
|
2606
|
+
context: 'model_run',
|
|
2607
|
+
jobId: job.id,
|
|
2608
|
+
});
|
|
2609
|
+
};
|
|
2200
2610
|
const provider = getGatewayCliProvider(providerId);
|
|
2201
2611
|
let normalizedPrompt = prompt;
|
|
2202
2612
|
let normalizedSchema = jsonSchema;
|
|
@@ -2260,17 +2670,20 @@ async function handleModelRunJob(_supabase, job) {
|
|
|
2260
2670
|
}
|
|
2261
2671
|
await updateJobDebug(_supabase, job.id, debugInfo);
|
|
2262
2672
|
if (!runResult.ok) {
|
|
2673
|
+
const summary = normalizeProviderErrorSummary(errorSummary?.message ?? runResult.error ?? 'Gateway model run failed');
|
|
2674
|
+
await updateProviderHealth('unhealthy', summary ?? 'Gateway model run failed');
|
|
2675
|
+
const failureMessage = buildProviderFailureMessage(providerId, summary);
|
|
2263
2676
|
return {
|
|
2264
2677
|
ok: false,
|
|
2265
2678
|
result: {
|
|
2266
|
-
message:
|
|
2679
|
+
message: failureMessage,
|
|
2267
2680
|
exit_code: runResult.exitCode,
|
|
2268
2681
|
stderr: stderrInfo.value,
|
|
2269
2682
|
stdout: stdoutInfo.value,
|
|
2270
2683
|
command: commandLine,
|
|
2271
2684
|
error_summary: errorSummary ?? null,
|
|
2272
2685
|
},
|
|
2273
|
-
error:
|
|
2686
|
+
error: failureMessage,
|
|
2274
2687
|
};
|
|
2275
2688
|
}
|
|
2276
2689
|
let normalized;
|
|
@@ -2285,12 +2698,21 @@ async function handleModelRunJob(_supabase, job) {
|
|
|
2285
2698
|
}
|
|
2286
2699
|
catch (error) {
|
|
2287
2700
|
const message = error instanceof Error ? error.message : 'Failed to normalize gateway output';
|
|
2701
|
+
const summary = normalizeProviderErrorSummary(message);
|
|
2702
|
+
await updateProviderHealth('unhealthy', summary ?? message);
|
|
2703
|
+
const failureMessage = buildProviderFailureMessage(providerId, summary ?? message);
|
|
2288
2704
|
return {
|
|
2289
2705
|
ok: false,
|
|
2290
|
-
result: {
|
|
2291
|
-
|
|
2706
|
+
result: {
|
|
2707
|
+
message: failureMessage,
|
|
2708
|
+
stdout: stdoutInfo.value,
|
|
2709
|
+
stderr: stderrInfo.value,
|
|
2710
|
+
command: commandLine,
|
|
2711
|
+
},
|
|
2712
|
+
error: failureMessage,
|
|
2292
2713
|
};
|
|
2293
2714
|
}
|
|
2715
|
+
await updateProviderHealth('healthy');
|
|
2294
2716
|
return {
|
|
2295
2717
|
ok: true,
|
|
2296
2718
|
result: {
|
|
@@ -2455,7 +2877,7 @@ async function processJob(supabase, config, job) {
|
|
|
2455
2877
|
job_type: claimed.job_type,
|
|
2456
2878
|
}, `Unsupported job type: ${claimed.job_type}`);
|
|
2457
2879
|
}
|
|
2458
|
-
async function startGateway(options) {
|
|
2880
|
+
async function startGateway(options, configResolution) {
|
|
2459
2881
|
const paths = resolveGatewayPaths(options);
|
|
2460
2882
|
const foregroundRequested = options.foreground === true;
|
|
2461
2883
|
const explicitDaemon = options.daemon === true || options.background === true;
|
|
@@ -2533,6 +2955,8 @@ async function startGateway(options) {
|
|
|
2533
2955
|
deviceName,
|
|
2534
2956
|
};
|
|
2535
2957
|
await saveConfig(config, options);
|
|
2958
|
+
CURRENT_CONFIG = config;
|
|
2959
|
+
CURRENT_DEVICE_NAME = deviceName;
|
|
2536
2960
|
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
|
2537
2961
|
auth: {
|
|
2538
2962
|
persistSession: false,
|
|
@@ -2583,6 +3007,8 @@ async function startGateway(options) {
|
|
|
2583
3007
|
}
|
|
2584
3008
|
let currentCapabilities = initialCapabilities;
|
|
2585
3009
|
let currentProviderHealth = providerHealth;
|
|
3010
|
+
CURRENT_CAPABILITIES = initialCapabilities;
|
|
3011
|
+
CURRENT_PROVIDER_HEALTH = providerHealth;
|
|
2586
3012
|
const providerStatus = (currentCapabilities.providers ?? {});
|
|
2587
3013
|
const initialStatus = anyProviderReady ? 'ready' : 'error';
|
|
2588
3014
|
logInfo('Gateway capabilities loaded', {
|
|
@@ -2596,6 +3022,22 @@ async function startGateway(options) {
|
|
|
2596
3022
|
])),
|
|
2597
3023
|
concurrency: GATEWAY_CONCURRENCY,
|
|
2598
3024
|
});
|
|
3025
|
+
if (configResolution?.migratedFrom) {
|
|
3026
|
+
await emitGatewayEvent(supabase, config, {
|
|
3027
|
+
level: 'warn',
|
|
3028
|
+
code: 'config_dir_fallback',
|
|
3029
|
+
message: 'Gateway config directory was not writable; using fallback location.',
|
|
3030
|
+
details: {
|
|
3031
|
+
previous: configResolution.migratedFrom,
|
|
3032
|
+
current: configResolution.configDir,
|
|
3033
|
+
},
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
await flushGatewayEvents(supabase, config);
|
|
3037
|
+
const ranFullValidation = Object.values(providerHealth).some((health) => health.check_type === 'full');
|
|
3038
|
+
if (ranFullValidation) {
|
|
3039
|
+
await emitProviderHealthEvents(supabase, config, providerHealth, 'startup');
|
|
3040
|
+
}
|
|
2599
3041
|
await sendHeartbeat(supabase, initialStatus, currentCapabilities, deviceName);
|
|
2600
3042
|
let heartbeatStatus = initialStatus;
|
|
2601
3043
|
await writePidFile(process.pid, options);
|
|
@@ -2607,11 +3049,13 @@ async function startGateway(options) {
|
|
|
2607
3049
|
try {
|
|
2608
3050
|
const { capabilities: nextCapabilities, providerHealth: nextProviderHealth, anyProviderReady: nextReady, } = await buildCapabilities({
|
|
2609
3051
|
validationMode: 'light',
|
|
2610
|
-
previousHealth: currentProviderHealth,
|
|
3052
|
+
previousHealth: CURRENT_PROVIDER_HEALTH ?? currentProviderHealth,
|
|
2611
3053
|
});
|
|
2612
3054
|
currentCapabilities = nextCapabilities;
|
|
2613
3055
|
currentProviderHealth = nextProviderHealth;
|
|
2614
3056
|
config.providerHealth = nextProviderHealth;
|
|
3057
|
+
CURRENT_CAPABILITIES = nextCapabilities;
|
|
3058
|
+
CURRENT_PROVIDER_HEALTH = nextProviderHealth;
|
|
2615
3059
|
heartbeatStatus = nextReady ? 'ready' : 'error';
|
|
2616
3060
|
await sendHeartbeat(supabase, heartbeatStatus, currentCapabilities, deviceName);
|
|
2617
3061
|
}
|
|
@@ -2827,6 +3271,64 @@ async function statusGateway(options) {
|
|
|
2827
3271
|
{ label: 'PID Path', value: formatPathForDisplay(pidPath), verboseOnly: true },
|
|
2828
3272
|
]);
|
|
2829
3273
|
}
|
|
3274
|
+
async function doctorGateway(options, configResolution) {
|
|
3275
|
+
const { configPath, configDir } = resolveGatewayPaths(options);
|
|
3276
|
+
const configExists = fsSync.existsSync(configPath);
|
|
3277
|
+
let config = null;
|
|
3278
|
+
if (configExists) {
|
|
3279
|
+
try {
|
|
3280
|
+
config = await loadConfig(options);
|
|
3281
|
+
}
|
|
3282
|
+
catch (error) {
|
|
3283
|
+
cliWarn('Unable to read gateway config.', options, [
|
|
3284
|
+
{ label: 'Config path', value: formatPathForDisplay(configPath), verboseOnly: true },
|
|
3285
|
+
{ label: 'Error', value: error instanceof Error ? error.message : String(error), verboseOnly: true },
|
|
3286
|
+
]);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
cliInfo('Gateway doctor report', options, [
|
|
3290
|
+
{ label: 'Config dir', value: formatPathForDisplay(configDir) },
|
|
3291
|
+
{ label: 'Paired', value: configExists ? 'Yes' : 'No' },
|
|
3292
|
+
{ label: 'Gateway ID', value: config?.gatewayId ?? undefined, verboseOnly: true },
|
|
3293
|
+
{ label: 'Team ID', value: config?.teamId ?? undefined, verboseOnly: true },
|
|
3294
|
+
]);
|
|
3295
|
+
await ensureClaudeGatewayHome();
|
|
3296
|
+
const { providerHealth, anyProviderReady } = await buildCapabilities({
|
|
3297
|
+
validationMode: 'full',
|
|
3298
|
+
previousHealth: config?.providerHealth,
|
|
3299
|
+
});
|
|
3300
|
+
if (config) {
|
|
3301
|
+
config.providerHealth = providerHealth;
|
|
3302
|
+
await saveConfig(config, options);
|
|
3303
|
+
}
|
|
3304
|
+
const providerEntries = Object.entries(providerHealth);
|
|
3305
|
+
console.log('Provider status:');
|
|
3306
|
+
for (const [providerId, health] of providerEntries) {
|
|
3307
|
+
const error = health.error ? ` (${truncateText(health.error, 200)})` : '';
|
|
3308
|
+
console.log(` - ${providerId}: ${health.status}${error}`);
|
|
3309
|
+
}
|
|
3310
|
+
if (!anyProviderReady) {
|
|
3311
|
+
cliWarn('No providers passed validation. Install/authenticate a provider before starting the gateway.', options);
|
|
3312
|
+
}
|
|
3313
|
+
if (config) {
|
|
3314
|
+
const eventClient = await createGatewayEventClient(config);
|
|
3315
|
+
if (eventClient) {
|
|
3316
|
+
await flushGatewayEvents(eventClient, config);
|
|
3317
|
+
if (configResolution?.migratedFrom) {
|
|
3318
|
+
await emitGatewayEvent(eventClient, config, {
|
|
3319
|
+
level: 'warn',
|
|
3320
|
+
code: 'config_dir_fallback',
|
|
3321
|
+
message: 'Gateway config directory was not writable; using fallback location.',
|
|
3322
|
+
details: {
|
|
3323
|
+
previous: configResolution.migratedFrom,
|
|
3324
|
+
current: configResolution.configDir,
|
|
3325
|
+
},
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
await emitProviderHealthEvents(eventClient, config, providerHealth, 'doctor');
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
2830
3332
|
async function showLogs(options) {
|
|
2831
3333
|
const linesRaw = getStringOption(options, 'lines');
|
|
2832
3334
|
const lines = linesRaw ? Number.parseInt(linesRaw, 10) : 200;
|
|
@@ -2897,16 +3399,20 @@ async function run() {
|
|
|
2897
3399
|
}
|
|
2898
3400
|
if (parsed.command === 'pair') {
|
|
2899
3401
|
loadEnvironment(parsed.options);
|
|
3402
|
+
const configResolution = await prepareGatewayConfigDir(parsed.options, true);
|
|
3403
|
+
applyOptionEnvOverrides(parsed.options);
|
|
2900
3404
|
const code = getStringOption(parsed.options, 'code') || parsed.positional[0];
|
|
2901
3405
|
if (!code) {
|
|
2902
3406
|
throw new Error('Pairing code is required');
|
|
2903
3407
|
}
|
|
2904
|
-
await pairGateway(code, parsed.options);
|
|
3408
|
+
await pairGateway(code, parsed.options, configResolution);
|
|
2905
3409
|
return;
|
|
2906
3410
|
}
|
|
2907
3411
|
if (parsed.command === 'start') {
|
|
2908
3412
|
loadEnvironment(parsed.options);
|
|
2909
|
-
await
|
|
3413
|
+
const configResolution = await prepareGatewayConfigDir(parsed.options, true);
|
|
3414
|
+
applyOptionEnvOverrides(parsed.options);
|
|
3415
|
+
await startGateway(parsed.options, configResolution);
|
|
2910
3416
|
return;
|
|
2911
3417
|
}
|
|
2912
3418
|
if (parsed.command === 'stop') {
|
|
@@ -2921,6 +3427,13 @@ async function run() {
|
|
|
2921
3427
|
await showLogs(parsed.options);
|
|
2922
3428
|
return;
|
|
2923
3429
|
}
|
|
3430
|
+
if (parsed.command === 'doctor') {
|
|
3431
|
+
loadEnvironment(parsed.options);
|
|
3432
|
+
const configResolution = await prepareGatewayConfigDir(parsed.options, true);
|
|
3433
|
+
applyOptionEnvOverrides(parsed.options);
|
|
3434
|
+
await doctorGateway(parsed.options, configResolution);
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
2924
3437
|
printHelp();
|
|
2925
3438
|
process.exitCode = 1;
|
|
2926
3439
|
}
|