@panorama-ai/gateway 2.24.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/cli-providers/claude-utils.d.ts +10 -0
- package/dist/cli-providers/claude-utils.d.ts.map +1 -0
- package/dist/cli-providers/claude-utils.js +73 -0
- package/dist/cli-providers/claude-utils.js.map +1 -0
- package/dist/cli-providers/claude.d.ts +3 -0
- package/dist/cli-providers/claude.d.ts.map +1 -0
- package/dist/cli-providers/claude.js +212 -0
- package/dist/cli-providers/claude.js.map +1 -0
- package/dist/cli-providers/codex-schema.d.ts +10 -0
- package/dist/cli-providers/codex-schema.d.ts.map +1 -0
- package/dist/cli-providers/codex-schema.js +76 -0
- package/dist/cli-providers/codex-schema.js.map +1 -0
- package/dist/cli-providers/codex.d.ts +3 -0
- package/dist/cli-providers/codex.d.ts.map +1 -0
- package/dist/cli-providers/codex.js +271 -0
- package/dist/cli-providers/codex.js.map +1 -0
- package/dist/cli-providers/gemini.d.ts +3 -0
- package/dist/cli-providers/gemini.d.ts.map +1 -0
- package/dist/cli-providers/gemini.js +214 -0
- package/dist/cli-providers/gemini.js.map +1 -0
- package/dist/cli-providers/registry.d.ts +5 -0
- package/dist/cli-providers/registry.d.ts.map +1 -0
- package/dist/cli-providers/registry.js +25 -0
- package/dist/cli-providers/registry.js.map +1 -0
- package/dist/cli-providers/types.d.ts +61 -0
- package/dist/cli-providers/types.d.ts.map +1 -0
- package/dist/cli-providers/types.js +2 -0
- package/dist/cli-providers/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2288 -0
- package/dist/index.js.map +1 -0
- package/dist/subagent-adapters/claude-code.d.ts +3 -0
- package/dist/subagent-adapters/claude-code.d.ts.map +1 -0
- package/dist/subagent-adapters/claude-code.js +565 -0
- package/dist/subagent-adapters/claude-code.js.map +1 -0
- package/dist/subagent-adapters/claude-support.d.ts +30 -0
- package/dist/subagent-adapters/claude-support.d.ts.map +1 -0
- package/dist/subagent-adapters/claude-support.js +67 -0
- package/dist/subagent-adapters/claude-support.js.map +1 -0
- package/dist/subagent-adapters/codex.d.ts +3 -0
- package/dist/subagent-adapters/codex.d.ts.map +1 -0
- package/dist/subagent-adapters/codex.js +241 -0
- package/dist/subagent-adapters/codex.js.map +1 -0
- package/dist/subagent-adapters/gemini.d.ts +3 -0
- package/dist/subagent-adapters/gemini.d.ts.map +1 -0
- package/dist/subagent-adapters/gemini.js +257 -0
- package/dist/subagent-adapters/gemini.js.map +1 -0
- package/dist/subagent-adapters/registry.d.ts +4 -0
- package/dist/subagent-adapters/registry.d.ts.map +1 -0
- package/dist/subagent-adapters/registry.js +19 -0
- package/dist/subagent-adapters/registry.js.map +1 -0
- package/dist/subagent-adapters/types.d.ts +60 -0
- package/dist/subagent-adapters/types.d.ts.map +1 -0
- package/dist/subagent-adapters/types.js +2 -0
- package/dist/subagent-adapters/types.js.map +1 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2288 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import fsSync from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { DEFAULT_CLAUDE_SUPPORT, coerceClaudeSupport, parseClaudeSupport, toFlagRecord, } from './subagent-adapters/claude-support.js';
|
|
12
|
+
import { getSubagentAdapter } from './subagent-adapters/registry.js';
|
|
13
|
+
import { SUBAGENT_SCHEMA_VERSION, } from './subagent-adapters/types.js';
|
|
14
|
+
import { listGatewayCliProviders, getGatewayCliProvider, inferGatewayProviderFromModel } from './cli-providers/registry.js';
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
17
|
+
const DEFAULT_CLAUDE_TIMEOUT_MS = 10_000;
|
|
18
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60_000;
|
|
19
|
+
const DEFAULT_SUBAGENT_RETRY_MAX_ATTEMPTS = 3;
|
|
20
|
+
const DEFAULT_SUBAGENT_RETRY_BACKOFF_MS = 2_000;
|
|
21
|
+
const DEFAULT_SUBAGENT_RETRY_MAX_BACKOFF_MS = 15_000;
|
|
22
|
+
const DEFAULT_MODEL_RUN_TIMEOUT_MS = 110_000;
|
|
23
|
+
const DEFAULT_GATEWAY_CONCURRENCY = 10;
|
|
24
|
+
const MAX_SUBAGENT_OUTPUT_BYTES = 5_000_000;
|
|
25
|
+
const MAX_SUBAGENT_EVENT_PAYLOAD_BYTES = 200_000;
|
|
26
|
+
const MAX_GATEWAY_LOG_BYTES = 200_000;
|
|
27
|
+
const SUBAGENT_EVENT_BATCH_SIZE = 200;
|
|
28
|
+
const SUBAGENT_CANCEL_KILL_TIMEOUT_MS = 5_000;
|
|
29
|
+
const PROCESS_KILL_GRACE_MS = 2_000;
|
|
30
|
+
const VALID_ENVIRONMENTS = new Set(['local', 'dev', 'test', 'stage', 'prod']);
|
|
31
|
+
const CLAUDE_ENV_ALLOWLIST = [
|
|
32
|
+
'PATH',
|
|
33
|
+
'HOME',
|
|
34
|
+
'USER',
|
|
35
|
+
'LOGNAME',
|
|
36
|
+
'SHELL',
|
|
37
|
+
'LANG',
|
|
38
|
+
'LC_ALL',
|
|
39
|
+
'LC_CTYPE',
|
|
40
|
+
'TERM',
|
|
41
|
+
'TMPDIR',
|
|
42
|
+
'TMP',
|
|
43
|
+
'TEMP',
|
|
44
|
+
'HTTP_PROXY',
|
|
45
|
+
'HTTPS_PROXY',
|
|
46
|
+
'NO_PROXY',
|
|
47
|
+
'http_proxy',
|
|
48
|
+
'https_proxy',
|
|
49
|
+
'no_proxy',
|
|
50
|
+
'XDG_CONFIG_HOME',
|
|
51
|
+
'XDG_CACHE_HOME',
|
|
52
|
+
'XDG_DATA_HOME',
|
|
53
|
+
'SSH_AUTH_SOCK',
|
|
54
|
+
'SSH_AGENT_PID',
|
|
55
|
+
];
|
|
56
|
+
const CONFIG_DIR = process.env.PANORAMA_GATEWAY_CONFIG_DIR || path.join(os.homedir(), '.panorama');
|
|
57
|
+
const SUBAGENT_WORKDIR_ROOT = path.join(CONFIG_DIR, 'subagents');
|
|
58
|
+
const CLAUDE_GATEWAY_HOME = process.env.PANORAMA_CLAUDE_HOME || null;
|
|
59
|
+
const CONFIG_PATH = process.env.PANORAMA_GATEWAY_CONFIG_PATH || path.join(CONFIG_DIR, 'gateway.json');
|
|
60
|
+
const LOG_PATH = process.env.PANORAMA_GATEWAY_LOG_PATH || path.join(path.dirname(CONFIG_PATH), 'gateway.log');
|
|
61
|
+
const PID_PATH = process.env.PANORAMA_GATEWAY_PID_PATH || path.join(path.dirname(CONFIG_PATH), 'gateway.pid');
|
|
62
|
+
const DEFAULT_CLAUDE_PATH = path.join(os.homedir(), '.claude', 'local', 'claude');
|
|
63
|
+
const CLAUDE_COMMAND = process.env.PANORAMA_CLAUDE_CLI ||
|
|
64
|
+
process.env.CLAUDE_CLI ||
|
|
65
|
+
(fsSync.existsSync(DEFAULT_CLAUDE_PATH) ? DEFAULT_CLAUDE_PATH : 'claude');
|
|
66
|
+
const CODEX_COMMAND = process.env.PANORAMA_CODEX_CLI || process.env.CODEX_CLI || 'codex';
|
|
67
|
+
const GEMINI_COMMAND = process.env.PANORAMA_GEMINI_CLI || process.env.GEMINI_CLI || 'gemini';
|
|
68
|
+
let CLAUDE_SUPPORT = null;
|
|
69
|
+
let PROVIDER_CAPABILITIES = null;
|
|
70
|
+
const GATEWAY_CONCURRENCY = (() => {
|
|
71
|
+
const raw = process.env.PANORAMA_GATEWAY_CONCURRENCY;
|
|
72
|
+
if (!raw)
|
|
73
|
+
return DEFAULT_GATEWAY_CONCURRENCY;
|
|
74
|
+
const parsed = Number.parseInt(raw, 10);
|
|
75
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
76
|
+
return DEFAULT_GATEWAY_CONCURRENCY;
|
|
77
|
+
return parsed;
|
|
78
|
+
})();
|
|
79
|
+
const activeSubagentRuns = new Map();
|
|
80
|
+
const pendingCancelByRunId = new Map();
|
|
81
|
+
const pendingCancelBySubagentId = new Map();
|
|
82
|
+
function parseArgs(argv) {
|
|
83
|
+
const positional = [];
|
|
84
|
+
const options = {};
|
|
85
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
86
|
+
const arg = argv[i];
|
|
87
|
+
if (!arg)
|
|
88
|
+
continue;
|
|
89
|
+
if (arg.startsWith('--')) {
|
|
90
|
+
const [flag, inlineValue] = arg.slice(2).split('=');
|
|
91
|
+
if (inlineValue !== undefined) {
|
|
92
|
+
options[flag] = inlineValue;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const next = argv[i + 1];
|
|
96
|
+
if (next && !next.startsWith('--')) {
|
|
97
|
+
options[flag] = next;
|
|
98
|
+
i += 1;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
options[flag] = true;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (arg.startsWith('-') && arg.length > 1) {
|
|
106
|
+
options[arg.slice(1)] = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
positional.push(arg);
|
|
110
|
+
}
|
|
111
|
+
const command = positional.shift() ?? null;
|
|
112
|
+
return { command, positional, options };
|
|
113
|
+
}
|
|
114
|
+
function printHelp() {
|
|
115
|
+
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\"] [--daemon]\n panorama-gateway stop\n panorama-gateway logs [--lines 200] [--no-follow]\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\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_PATH to override config path\n PANORAMA_GATEWAY_LOG_PATH to override log path\n PANORAMA_GATEWAY_PID_PATH to override pid path\n PANORAMA_CLAUDE_CLI or CLAUDE_CLI to override claude command\n`);
|
|
116
|
+
}
|
|
117
|
+
function getStringOption(options, key) {
|
|
118
|
+
const value = options[key];
|
|
119
|
+
if (typeof value === 'string')
|
|
120
|
+
return value;
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function normalizeEnvName(raw) {
|
|
124
|
+
if (!raw)
|
|
125
|
+
return null;
|
|
126
|
+
const value = raw.trim().toLowerCase();
|
|
127
|
+
if (!value)
|
|
128
|
+
return null;
|
|
129
|
+
if (value === 'production')
|
|
130
|
+
return 'prod';
|
|
131
|
+
if (value === 'development')
|
|
132
|
+
return 'local';
|
|
133
|
+
if (VALID_ENVIRONMENTS.has(value))
|
|
134
|
+
return value;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function findRepoRoot(startDir) {
|
|
138
|
+
let current = startDir;
|
|
139
|
+
while (true) {
|
|
140
|
+
if (fsSync.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
|
|
141
|
+
return current;
|
|
142
|
+
}
|
|
143
|
+
const parent = path.dirname(current);
|
|
144
|
+
if (parent === current)
|
|
145
|
+
return null;
|
|
146
|
+
current = parent;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function loadEnvironment(options) {
|
|
150
|
+
const envFileOption = getStringOption(options, 'env-file') ||
|
|
151
|
+
process.env.PANORAMA_ENV_FILE ||
|
|
152
|
+
process.env.PANORAMA_ENV_PATH;
|
|
153
|
+
const envNameOption = getStringOption(options, 'env') || process.env.PANORAMA_ENV || process.env.ENVIRONMENT;
|
|
154
|
+
const envName = normalizeEnvName(envNameOption) ?? 'local';
|
|
155
|
+
const envPath = envFileOption
|
|
156
|
+
? path.resolve(envFileOption)
|
|
157
|
+
: path.join(findRepoRoot(process.cwd()) ?? process.cwd(), envName === 'local' ? '.env' : `.env.${envName}`);
|
|
158
|
+
if (!fsSync.existsSync(envPath)) {
|
|
159
|
+
if (envFileOption || envNameOption) {
|
|
160
|
+
throw new Error(`Environment file not found: ${envPath}`);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
dotenv.config({ path: envPath });
|
|
165
|
+
}
|
|
166
|
+
function resolveSupabaseUrl(options, config) {
|
|
167
|
+
return (getStringOption(options, 'supabase-url') ||
|
|
168
|
+
process.env.PANORAMA_SUPABASE_URL ||
|
|
169
|
+
process.env.SUPABASE_URL ||
|
|
170
|
+
config?.supabaseUrl ||
|
|
171
|
+
'');
|
|
172
|
+
}
|
|
173
|
+
function resolveSupabaseAnonKey(options, config) {
|
|
174
|
+
return (getStringOption(options, 'anon-key') ||
|
|
175
|
+
process.env.PANORAMA_SUPABASE_ANON_KEY ||
|
|
176
|
+
process.env.SUPABASE_ANON_KEY ||
|
|
177
|
+
process.env.SUPABASE_PUBLISHABLE_KEY ||
|
|
178
|
+
config?.supabaseAnonKey ||
|
|
179
|
+
'');
|
|
180
|
+
}
|
|
181
|
+
async function loadConfig() {
|
|
182
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
183
|
+
return JSON.parse(raw);
|
|
184
|
+
}
|
|
185
|
+
async function saveConfig(config) {
|
|
186
|
+
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
187
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
188
|
+
}
|
|
189
|
+
function logInfo(message, data) {
|
|
190
|
+
if (data) {
|
|
191
|
+
console.log(`[gateway] ${message}`, data);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
console.log(`[gateway] ${message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function logError(message, data) {
|
|
198
|
+
if (data) {
|
|
199
|
+
console.error(`[gateway] ${message}`, data);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.error(`[gateway] ${message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function readPidFile() {
|
|
206
|
+
try {
|
|
207
|
+
const raw = fsSync.readFileSync(PID_PATH, 'utf-8').trim();
|
|
208
|
+
const pid = Number.parseInt(raw, 10);
|
|
209
|
+
return Number.isFinite(pid) ? pid : null;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function writePidFile(pid) {
|
|
216
|
+
await fs.mkdir(path.dirname(PID_PATH), { recursive: true });
|
|
217
|
+
await fs.writeFile(PID_PATH, `${pid}\n`);
|
|
218
|
+
}
|
|
219
|
+
async function removePidFile() {
|
|
220
|
+
try {
|
|
221
|
+
await fs.unlink(PID_PATH);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// ignore missing pid
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function isProcessAlive(pid) {
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 0);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function safeJson(response) {
|
|
237
|
+
try {
|
|
238
|
+
return await response.json();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function pairGateway(code, options) {
|
|
245
|
+
const supabaseUrl = resolveSupabaseUrl(options);
|
|
246
|
+
const supabaseAnonKey = resolveSupabaseAnonKey(options);
|
|
247
|
+
if (!supabaseUrl || !supabaseAnonKey) {
|
|
248
|
+
throw new Error('Missing Supabase URL or anon key for pairing. Provide SUPABASE_URL/SUPABASE_ANON_KEY or use --env.');
|
|
249
|
+
}
|
|
250
|
+
const deviceName = getStringOption(options, 'device-name') || process.env.PANORAMA_GATEWAY_DEVICE_NAME || os.hostname();
|
|
251
|
+
const url = `${supabaseUrl.replace(/\/$/, '')}/functions/v1/exchange-gateway-pairing-code`;
|
|
252
|
+
const response = await fetch(url, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
'Content-Type': 'application/json',
|
|
256
|
+
apikey: supabaseAnonKey,
|
|
257
|
+
Authorization: `Bearer ${supabaseAnonKey}`,
|
|
258
|
+
},
|
|
259
|
+
body: JSON.stringify({
|
|
260
|
+
code: code.trim(),
|
|
261
|
+
device_name: deviceName,
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
const body = await safeJson(response);
|
|
265
|
+
if (!response.ok || body?.success === false) {
|
|
266
|
+
const message = body?.error || `Pairing failed with status ${response.status}`;
|
|
267
|
+
throw new Error(message);
|
|
268
|
+
}
|
|
269
|
+
if (!body?.access_token || !body?.refresh_token || !body?.gateway_id || !body?.team_id) {
|
|
270
|
+
throw new Error('Pairing response missing required fields');
|
|
271
|
+
}
|
|
272
|
+
const config = {
|
|
273
|
+
supabaseUrl,
|
|
274
|
+
supabaseAnonKey,
|
|
275
|
+
accessToken: body.access_token,
|
|
276
|
+
refreshToken: body.refresh_token,
|
|
277
|
+
gatewayId: body.gateway_id,
|
|
278
|
+
teamId: body.team_id,
|
|
279
|
+
deviceName,
|
|
280
|
+
};
|
|
281
|
+
await saveConfig(config);
|
|
282
|
+
logInfo('Gateway paired successfully', {
|
|
283
|
+
gatewayId: body.gateway_id,
|
|
284
|
+
teamId: body.team_id,
|
|
285
|
+
configPath: CONFIG_PATH,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async function execClaudeVersion() {
|
|
289
|
+
const start = Date.now();
|
|
290
|
+
try {
|
|
291
|
+
const { stdout, stderr } = await execFileAsync(CLAUDE_COMMAND, ['--version'], {
|
|
292
|
+
timeout: DEFAULT_CLAUDE_TIMEOUT_MS,
|
|
293
|
+
});
|
|
294
|
+
const durationMs = Date.now() - start;
|
|
295
|
+
const trimmed = stdout.trim();
|
|
296
|
+
return {
|
|
297
|
+
ok: true,
|
|
298
|
+
stdout,
|
|
299
|
+
stderr,
|
|
300
|
+
exitCode: 0,
|
|
301
|
+
durationMs,
|
|
302
|
+
version: trimmed || undefined,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const durationMs = Date.now() - start;
|
|
307
|
+
const err = error;
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
stdout: err.stdout ? String(err.stdout) : '',
|
|
311
|
+
stderr: err.stderr ? String(err.stderr) : '',
|
|
312
|
+
exitCode: typeof err.code === 'number' ? err.code : null,
|
|
313
|
+
durationMs,
|
|
314
|
+
error: err.message,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function execClaudeHelp() {
|
|
319
|
+
const start = Date.now();
|
|
320
|
+
try {
|
|
321
|
+
const { stdout, stderr } = await execFileAsync(CLAUDE_COMMAND, ['--help'], {
|
|
322
|
+
timeout: DEFAULT_CLAUDE_TIMEOUT_MS,
|
|
323
|
+
});
|
|
324
|
+
const durationMs = Date.now() - start;
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
stdout,
|
|
328
|
+
stderr,
|
|
329
|
+
exitCode: 0,
|
|
330
|
+
durationMs,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
const durationMs = Date.now() - start;
|
|
335
|
+
const err = error;
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
stdout: err.stdout ? String(err.stdout) : '',
|
|
339
|
+
stderr: err.stderr ? String(err.stderr) : '',
|
|
340
|
+
exitCode: typeof err.code === 'number' ? err.code : null,
|
|
341
|
+
durationMs,
|
|
342
|
+
error: err.message,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function detectClaudeSupport() {
|
|
347
|
+
const help = await execClaudeHelp();
|
|
348
|
+
if (!help.ok) {
|
|
349
|
+
return DEFAULT_CLAUDE_SUPPORT;
|
|
350
|
+
}
|
|
351
|
+
return parseClaudeSupport(`${help.stdout}\n${help.stderr}`);
|
|
352
|
+
}
|
|
353
|
+
async function buildCapabilities() {
|
|
354
|
+
const providers = listGatewayCliProviders();
|
|
355
|
+
const entries = await Promise.all(providers.map(async (provider) => {
|
|
356
|
+
try {
|
|
357
|
+
const capability = await provider.detectCapabilities();
|
|
358
|
+
return [provider.id, capability];
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
return [
|
|
362
|
+
provider.id,
|
|
363
|
+
{
|
|
364
|
+
available: false,
|
|
365
|
+
error: error instanceof Error ? error.message : String(error),
|
|
366
|
+
supports: { output_schema: false, stream_json: false, tool_disable: false },
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
}));
|
|
371
|
+
const providerCapabilities = entries.reduce((acc, [id, capability]) => {
|
|
372
|
+
acc[id] = capability;
|
|
373
|
+
return acc;
|
|
374
|
+
}, {});
|
|
375
|
+
PROVIDER_CAPABILITIES = providerCapabilities;
|
|
376
|
+
const claudeCapabilities = providerCapabilities.claude_code;
|
|
377
|
+
if (claudeCapabilities?.supported_flags) {
|
|
378
|
+
CLAUDE_SUPPORT = coerceClaudeSupport(claudeCapabilities.supported_flags);
|
|
379
|
+
}
|
|
380
|
+
const claudeCliLegacy = claudeCapabilities
|
|
381
|
+
? {
|
|
382
|
+
available: claudeCapabilities.available,
|
|
383
|
+
version: claudeCapabilities.version,
|
|
384
|
+
error: claudeCapabilities.error,
|
|
385
|
+
command: `${CLAUDE_COMMAND} --version`,
|
|
386
|
+
supported_flags: claudeCapabilities.supported_flags,
|
|
387
|
+
}
|
|
388
|
+
: {
|
|
389
|
+
available: false,
|
|
390
|
+
error: 'Claude CLI not detected',
|
|
391
|
+
supported_flags: DEFAULT_CLAUDE_SUPPORT,
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
providers: providerCapabilities,
|
|
395
|
+
claude_cli: claudeCliLegacy,
|
|
396
|
+
platform: process.platform,
|
|
397
|
+
arch: process.arch,
|
|
398
|
+
node_version: process.version,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function appendClaudeIsolationArgs(args, support) {
|
|
402
|
+
if (support.settingSourcesFlag) {
|
|
403
|
+
args.push('--setting-sources', 'local');
|
|
404
|
+
}
|
|
405
|
+
if (support.disableSlashCommandsFlag) {
|
|
406
|
+
args.push('--disable-slash-commands');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function ensureClaudeGatewayHome() {
|
|
410
|
+
if (!CLAUDE_GATEWAY_HOME)
|
|
411
|
+
return;
|
|
412
|
+
await fs.mkdir(CLAUDE_GATEWAY_HOME, { recursive: true });
|
|
413
|
+
await fs.mkdir(path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'config'), { recursive: true });
|
|
414
|
+
await fs.mkdir(path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'cache'), { recursive: true });
|
|
415
|
+
await fs.mkdir(path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'data'), { recursive: true });
|
|
416
|
+
const sourceSessionEnv = path.join(os.homedir(), '.claude', 'session-env');
|
|
417
|
+
const destSessionEnv = path.join(CLAUDE_GATEWAY_HOME, 'session-env');
|
|
418
|
+
try {
|
|
419
|
+
const srcStat = await fs.stat(sourceSessionEnv);
|
|
420
|
+
if (srcStat.isDirectory()) {
|
|
421
|
+
try {
|
|
422
|
+
await fs.stat(destSessionEnv);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
await fs.cp(sourceSessionEnv, destSessionEnv, { recursive: true });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// If there's no session env yet, leave it empty; Claude will prompt if needed.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function buildClaudeEnv() {
|
|
434
|
+
const env = {};
|
|
435
|
+
for (const key of CLAUDE_ENV_ALLOWLIST) {
|
|
436
|
+
const value = process.env[key];
|
|
437
|
+
if (value !== undefined) {
|
|
438
|
+
env[key] = value;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (CLAUDE_GATEWAY_HOME) {
|
|
442
|
+
env.HOME = CLAUDE_GATEWAY_HOME;
|
|
443
|
+
env.XDG_CONFIG_HOME = path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'config');
|
|
444
|
+
env.XDG_CACHE_HOME = path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'cache');
|
|
445
|
+
env.XDG_DATA_HOME = path.join(CLAUDE_GATEWAY_HOME, 'xdg', 'data');
|
|
446
|
+
}
|
|
447
|
+
return env;
|
|
448
|
+
}
|
|
449
|
+
async function runSubagentCommand(command, args, options) {
|
|
450
|
+
const start = Date.now();
|
|
451
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
|
|
452
|
+
return await new Promise((resolve) => {
|
|
453
|
+
const stdoutChunks = [];
|
|
454
|
+
const stderrChunks = [];
|
|
455
|
+
let stdoutBytes = 0;
|
|
456
|
+
let stderrBytes = 0;
|
|
457
|
+
let stdoutTruncated = false;
|
|
458
|
+
let stderrTruncated = false;
|
|
459
|
+
let timedOut = false;
|
|
460
|
+
const child = spawn(command, args, {
|
|
461
|
+
cwd: options.cwd,
|
|
462
|
+
env: options.env ?? buildClaudeEnv(),
|
|
463
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
464
|
+
});
|
|
465
|
+
options.onStart?.(child);
|
|
466
|
+
child.stdout?.on('data', (chunk) => {
|
|
467
|
+
if (stdoutBytes >= MAX_SUBAGENT_OUTPUT_BYTES) {
|
|
468
|
+
stdoutTruncated = true;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const remaining = MAX_SUBAGENT_OUTPUT_BYTES - stdoutBytes;
|
|
472
|
+
if (chunk.length > remaining) {
|
|
473
|
+
stdoutChunks.push(chunk.slice(0, remaining));
|
|
474
|
+
stdoutBytes = MAX_SUBAGENT_OUTPUT_BYTES;
|
|
475
|
+
stdoutTruncated = true;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
stdoutChunks.push(chunk);
|
|
479
|
+
stdoutBytes += chunk.length;
|
|
480
|
+
});
|
|
481
|
+
child.stderr?.on('data', (chunk) => {
|
|
482
|
+
if (stderrBytes >= MAX_SUBAGENT_OUTPUT_BYTES) {
|
|
483
|
+
stderrTruncated = true;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const remaining = MAX_SUBAGENT_OUTPUT_BYTES - stderrBytes;
|
|
487
|
+
if (chunk.length > remaining) {
|
|
488
|
+
stderrChunks.push(chunk.slice(0, remaining));
|
|
489
|
+
stderrBytes = MAX_SUBAGENT_OUTPUT_BYTES;
|
|
490
|
+
stderrTruncated = true;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
stderrChunks.push(chunk);
|
|
494
|
+
stderrBytes += chunk.length;
|
|
495
|
+
});
|
|
496
|
+
const timer = timeoutMs && Number.isFinite(timeoutMs)
|
|
497
|
+
? setTimeout(() => {
|
|
498
|
+
timedOut = true;
|
|
499
|
+
if (!child.killed) {
|
|
500
|
+
child.kill('SIGTERM');
|
|
501
|
+
setTimeout(() => {
|
|
502
|
+
if (!child.killed) {
|
|
503
|
+
child.kill('SIGKILL');
|
|
504
|
+
}
|
|
505
|
+
}, PROCESS_KILL_GRACE_MS);
|
|
506
|
+
}
|
|
507
|
+
}, timeoutMs)
|
|
508
|
+
: null;
|
|
509
|
+
const finalize = (exitCode, error) => {
|
|
510
|
+
if (timer)
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
const durationMs = Date.now() - start;
|
|
513
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
514
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
|
515
|
+
const ok = !timedOut && exitCode === 0 && !error;
|
|
516
|
+
resolve({
|
|
517
|
+
ok,
|
|
518
|
+
stdout,
|
|
519
|
+
stderr,
|
|
520
|
+
exitCode,
|
|
521
|
+
durationMs,
|
|
522
|
+
timedOut,
|
|
523
|
+
stdoutTruncated,
|
|
524
|
+
stderrTruncated,
|
|
525
|
+
error,
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
child.on('error', (error) => {
|
|
529
|
+
finalize(null, error.message);
|
|
530
|
+
});
|
|
531
|
+
child.on('close', (code) => {
|
|
532
|
+
finalize(code ?? null);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
async function runSubagentCommandStreaming(command, args, options) {
|
|
537
|
+
const start = Date.now();
|
|
538
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
|
|
539
|
+
return await new Promise((resolve) => {
|
|
540
|
+
const stdoutChunks = [];
|
|
541
|
+
const stderrChunks = [];
|
|
542
|
+
const events = [];
|
|
543
|
+
let stdoutBytes = 0;
|
|
544
|
+
let stderrBytes = 0;
|
|
545
|
+
let stdoutTruncated = false;
|
|
546
|
+
let stderrTruncated = false;
|
|
547
|
+
let timedOut = false;
|
|
548
|
+
let buffer = '';
|
|
549
|
+
let sequence = 0;
|
|
550
|
+
const child = spawn(command, args, {
|
|
551
|
+
cwd: options.cwd,
|
|
552
|
+
env: options.env ?? buildClaudeEnv(),
|
|
553
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
554
|
+
});
|
|
555
|
+
options.onStart?.(child);
|
|
556
|
+
const captureStdout = (chunk) => {
|
|
557
|
+
if (stdoutBytes >= MAX_SUBAGENT_OUTPUT_BYTES) {
|
|
558
|
+
stdoutTruncated = true;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const remaining = MAX_SUBAGENT_OUTPUT_BYTES - stdoutBytes;
|
|
562
|
+
if (chunk.length > remaining) {
|
|
563
|
+
stdoutChunks.push(chunk.slice(0, remaining));
|
|
564
|
+
stdoutBytes = MAX_SUBAGENT_OUTPUT_BYTES;
|
|
565
|
+
stdoutTruncated = true;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
stdoutChunks.push(chunk);
|
|
569
|
+
stdoutBytes += chunk.length;
|
|
570
|
+
};
|
|
571
|
+
const parseLine = (line) => {
|
|
572
|
+
const trimmed = line.trim();
|
|
573
|
+
if (!trimmed)
|
|
574
|
+
return;
|
|
575
|
+
let event = null;
|
|
576
|
+
try {
|
|
577
|
+
event = JSON.parse(trimmed);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
event = null;
|
|
581
|
+
}
|
|
582
|
+
events.push({
|
|
583
|
+
sequence,
|
|
584
|
+
raw: trimmed,
|
|
585
|
+
event,
|
|
586
|
+
});
|
|
587
|
+
sequence += 1;
|
|
588
|
+
};
|
|
589
|
+
child.stdout?.on('data', (chunk) => {
|
|
590
|
+
captureStdout(chunk);
|
|
591
|
+
buffer += chunk.toString('utf-8');
|
|
592
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
593
|
+
while (newlineIndex >= 0) {
|
|
594
|
+
const line = buffer.slice(0, newlineIndex);
|
|
595
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
596
|
+
parseLine(line);
|
|
597
|
+
newlineIndex = buffer.indexOf('\n');
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
child.stderr?.on('data', (chunk) => {
|
|
601
|
+
if (stderrBytes >= MAX_SUBAGENT_OUTPUT_BYTES) {
|
|
602
|
+
stderrTruncated = true;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const remaining = MAX_SUBAGENT_OUTPUT_BYTES - stderrBytes;
|
|
606
|
+
if (chunk.length > remaining) {
|
|
607
|
+
stderrChunks.push(chunk.slice(0, remaining));
|
|
608
|
+
stderrBytes = MAX_SUBAGENT_OUTPUT_BYTES;
|
|
609
|
+
stderrTruncated = true;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
stderrChunks.push(chunk);
|
|
613
|
+
stderrBytes += chunk.length;
|
|
614
|
+
});
|
|
615
|
+
const timer = timeoutMs && Number.isFinite(timeoutMs)
|
|
616
|
+
? setTimeout(() => {
|
|
617
|
+
timedOut = true;
|
|
618
|
+
if (!child.killed) {
|
|
619
|
+
child.kill('SIGTERM');
|
|
620
|
+
setTimeout(() => {
|
|
621
|
+
if (!child.killed) {
|
|
622
|
+
child.kill('SIGKILL');
|
|
623
|
+
}
|
|
624
|
+
}, PROCESS_KILL_GRACE_MS);
|
|
625
|
+
}
|
|
626
|
+
}, timeoutMs)
|
|
627
|
+
: null;
|
|
628
|
+
const finalize = (exitCode, error) => {
|
|
629
|
+
if (timer)
|
|
630
|
+
clearTimeout(timer);
|
|
631
|
+
if (buffer.trim().length > 0) {
|
|
632
|
+
parseLine(buffer);
|
|
633
|
+
}
|
|
634
|
+
const durationMs = Date.now() - start;
|
|
635
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
636
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
|
637
|
+
const ok = !timedOut && exitCode === 0 && !error;
|
|
638
|
+
resolve({
|
|
639
|
+
ok,
|
|
640
|
+
stdout,
|
|
641
|
+
stderr,
|
|
642
|
+
exitCode,
|
|
643
|
+
durationMs,
|
|
644
|
+
timedOut,
|
|
645
|
+
stdoutTruncated,
|
|
646
|
+
stderrTruncated,
|
|
647
|
+
events,
|
|
648
|
+
error,
|
|
649
|
+
});
|
|
650
|
+
};
|
|
651
|
+
child.on('error', (error) => {
|
|
652
|
+
finalize(null, error.message);
|
|
653
|
+
});
|
|
654
|
+
child.on('close', (code) => {
|
|
655
|
+
finalize(code ?? null);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function extractSubagentPrompt(input) {
|
|
660
|
+
if (!input)
|
|
661
|
+
return null;
|
|
662
|
+
const raw = (typeof input.prompt === 'string' && input.prompt) ||
|
|
663
|
+
(typeof input.query === 'string' && input.query) ||
|
|
664
|
+
(typeof input.task === 'string' && input.task);
|
|
665
|
+
if (!raw)
|
|
666
|
+
return null;
|
|
667
|
+
const trimmed = raw.trim();
|
|
668
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
669
|
+
}
|
|
670
|
+
function isUuid(value) {
|
|
671
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
672
|
+
}
|
|
673
|
+
async function ensureSubagentWorkDir(subagentId) {
|
|
674
|
+
const dir = path.join(SUBAGENT_WORKDIR_ROOT, subagentId);
|
|
675
|
+
await fs.mkdir(dir, { recursive: true });
|
|
676
|
+
return dir;
|
|
677
|
+
}
|
|
678
|
+
function trimEventPayload(event) {
|
|
679
|
+
try {
|
|
680
|
+
const raw = JSON.stringify(event);
|
|
681
|
+
if (raw.length <= MAX_SUBAGENT_EVENT_PAYLOAD_BYTES)
|
|
682
|
+
return event;
|
|
683
|
+
return {
|
|
684
|
+
truncated: true,
|
|
685
|
+
original_bytes: raw.length,
|
|
686
|
+
preview: raw.slice(0, MAX_SUBAGENT_EVENT_PAYLOAD_BYTES),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return event;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function truncateLog(value) {
|
|
694
|
+
if (!value) {
|
|
695
|
+
return { value: '', truncated: false, originalBytes: 0 };
|
|
696
|
+
}
|
|
697
|
+
if (value.length <= MAX_GATEWAY_LOG_BYTES) {
|
|
698
|
+
return { value, truncated: false, originalBytes: value.length };
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
value: value.slice(0, MAX_GATEWAY_LOG_BYTES),
|
|
702
|
+
truncated: true,
|
|
703
|
+
originalBytes: value.length,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function extractFirstJsonObject(text) {
|
|
707
|
+
let inString = false;
|
|
708
|
+
let escape = false;
|
|
709
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
710
|
+
const char = text[i];
|
|
711
|
+
if (escape) {
|
|
712
|
+
escape = false;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (char === '\\\\') {
|
|
716
|
+
if (inString)
|
|
717
|
+
escape = true;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (char === '"') {
|
|
721
|
+
inString = !inString;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (inString)
|
|
725
|
+
continue;
|
|
726
|
+
if (char !== '{')
|
|
727
|
+
continue;
|
|
728
|
+
let depth = 0;
|
|
729
|
+
let innerInString = false;
|
|
730
|
+
let innerEscape = false;
|
|
731
|
+
for (let j = i; j < text.length; j += 1) {
|
|
732
|
+
const inner = text[j];
|
|
733
|
+
if (innerEscape) {
|
|
734
|
+
innerEscape = false;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (inner === '\\\\') {
|
|
738
|
+
if (innerInString)
|
|
739
|
+
innerEscape = true;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (inner === '"') {
|
|
743
|
+
innerInString = !innerInString;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (innerInString)
|
|
747
|
+
continue;
|
|
748
|
+
if (inner === '{')
|
|
749
|
+
depth += 1;
|
|
750
|
+
if (inner === '}') {
|
|
751
|
+
depth -= 1;
|
|
752
|
+
if (depth === 0) {
|
|
753
|
+
const candidate = text.slice(i, j + 1);
|
|
754
|
+
try {
|
|
755
|
+
return JSON.parse(candidate);
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
function extractGatewayErrorSummary(stderr) {
|
|
767
|
+
if (!stderr)
|
|
768
|
+
return null;
|
|
769
|
+
const parsed = extractFirstJsonObject(stderr);
|
|
770
|
+
if (!parsed)
|
|
771
|
+
return null;
|
|
772
|
+
const error = parsed.error;
|
|
773
|
+
if (error && typeof error === 'object') {
|
|
774
|
+
return {
|
|
775
|
+
message: typeof error.message === 'string' ? error.message : undefined,
|
|
776
|
+
type: typeof error.type === 'string' ? error.type : undefined,
|
|
777
|
+
code: typeof error.code === 'string' ? error.code : undefined,
|
|
778
|
+
param: typeof error.param === 'string' ? error.param : undefined,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
message: typeof parsed.message === 'string' ? parsed.message : undefined,
|
|
783
|
+
type: typeof parsed.type === 'string' ? String(parsed.type) : undefined,
|
|
784
|
+
code: typeof parsed.code === 'string' ? String(parsed.code) : undefined,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
function describeGatewayJob(job) {
|
|
788
|
+
if (job.job_type === 'model_run') {
|
|
789
|
+
const payload = job.payload ?? {};
|
|
790
|
+
const model = typeof payload.model === 'string' ? payload.model : null;
|
|
791
|
+
const provider = typeof payload.provider === 'string'
|
|
792
|
+
? payload.provider
|
|
793
|
+
: model
|
|
794
|
+
? inferGatewayProviderFromModel(model)
|
|
795
|
+
: null;
|
|
796
|
+
return { job_kind: 'cycle', provider, model };
|
|
797
|
+
}
|
|
798
|
+
if (job.job_type === 'subagent_run') {
|
|
799
|
+
const payload = job.payload ?? {};
|
|
800
|
+
const subagentType = typeof payload.subagent_type === 'string' ? payload.subagent_type : null;
|
|
801
|
+
const provider = inferProviderFromSubagentType(subagentType);
|
|
802
|
+
return { job_kind: 'subagent', provider, subagent_type: subagentType };
|
|
803
|
+
}
|
|
804
|
+
if (job.job_type === 'subagent_cancel') {
|
|
805
|
+
return { job_kind: 'subagent_cancel' };
|
|
806
|
+
}
|
|
807
|
+
return { job_kind: job.job_type };
|
|
808
|
+
}
|
|
809
|
+
function inferProviderFromSubagentType(subagentType) {
|
|
810
|
+
if (!subagentType)
|
|
811
|
+
return null;
|
|
812
|
+
const lowered = subagentType.toLowerCase();
|
|
813
|
+
if (lowered.includes('claude'))
|
|
814
|
+
return 'claude_code';
|
|
815
|
+
if (lowered.includes('codex'))
|
|
816
|
+
return 'codex';
|
|
817
|
+
if (lowered.includes('gemini'))
|
|
818
|
+
return 'gemini';
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
function resolveSubagentRetryConfig(config) {
|
|
822
|
+
const rawMaxAttempts = typeof config.max_attempts === 'number' ? config.max_attempts : null;
|
|
823
|
+
const rawBackoffMs = typeof config.retry_backoff_ms === 'number' ? config.retry_backoff_ms : null;
|
|
824
|
+
const rawMaxBackoffMs = typeof config.retry_max_backoff_ms === 'number' ? config.retry_max_backoff_ms : null;
|
|
825
|
+
const maxAttempts = rawMaxAttempts && Number.isFinite(rawMaxAttempts) && rawMaxAttempts > 0
|
|
826
|
+
? Math.floor(rawMaxAttempts)
|
|
827
|
+
: DEFAULT_SUBAGENT_RETRY_MAX_ATTEMPTS;
|
|
828
|
+
const backoffMs = rawBackoffMs && Number.isFinite(rawBackoffMs) && rawBackoffMs > 0
|
|
829
|
+
? rawBackoffMs
|
|
830
|
+
: DEFAULT_SUBAGENT_RETRY_BACKOFF_MS;
|
|
831
|
+
const maxBackoffMs = rawMaxBackoffMs && Number.isFinite(rawMaxBackoffMs) && rawMaxBackoffMs > 0
|
|
832
|
+
? rawMaxBackoffMs
|
|
833
|
+
: DEFAULT_SUBAGENT_RETRY_MAX_BACKOFF_MS;
|
|
834
|
+
return {
|
|
835
|
+
maxAttempts,
|
|
836
|
+
backoffMs,
|
|
837
|
+
maxBackoffMs,
|
|
838
|
+
retryOnCapacity: config.retry_on_capacity !== false,
|
|
839
|
+
retryOnTimeout: config.retry_on_timeout !== false,
|
|
840
|
+
retryOnNetwork: config.retry_on_network !== false,
|
|
841
|
+
retryOnParse: config.retry_on_parse !== false,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function computeRetryDelayMs(attempt, baseMs, maxMs) {
|
|
845
|
+
const exponent = Math.max(0, attempt - 1);
|
|
846
|
+
const raw = Math.min(maxMs, baseMs * Math.pow(2, exponent));
|
|
847
|
+
const jitter = 0.7 + Math.random() * 0.6;
|
|
848
|
+
return Math.round(raw * jitter);
|
|
849
|
+
}
|
|
850
|
+
async function sleep(ms) {
|
|
851
|
+
if (!ms || ms <= 0)
|
|
852
|
+
return;
|
|
853
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
854
|
+
}
|
|
855
|
+
function classifySubagentRetryable(params) {
|
|
856
|
+
const { result, parseError, parseStrict, config } = params;
|
|
857
|
+
if (parseStrict && parseError && config.retryOnParse) {
|
|
858
|
+
return { retryable: true, reason: 'parse_error' };
|
|
859
|
+
}
|
|
860
|
+
if (!result.ok) {
|
|
861
|
+
if (result.timedOut && config.retryOnTimeout) {
|
|
862
|
+
return { retryable: true, reason: 'timeout' };
|
|
863
|
+
}
|
|
864
|
+
const message = `${result.error ?? ''}\n${result.stderr ?? ''}`.toLowerCase();
|
|
865
|
+
if (config.retryOnCapacity) {
|
|
866
|
+
if (message.includes('resource_exhausted') ||
|
|
867
|
+
message.includes('capacity') ||
|
|
868
|
+
message.includes('rate limit') ||
|
|
869
|
+
message.includes('too many requests') ||
|
|
870
|
+
message.includes('429')) {
|
|
871
|
+
return { retryable: true, reason: 'capacity' };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (config.retryOnNetwork) {
|
|
875
|
+
if (message.includes('econnreset') ||
|
|
876
|
+
message.includes('etimedout') ||
|
|
877
|
+
message.includes('enotfound') ||
|
|
878
|
+
message.includes('eai_again') ||
|
|
879
|
+
message.includes('socket hang up') ||
|
|
880
|
+
message.includes('network')) {
|
|
881
|
+
return { retryable: true, reason: 'network' };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
async function getNextRunSequence(supabase, subagentId) {
|
|
888
|
+
const { data } = await supabase
|
|
889
|
+
.from('subagent_runs')
|
|
890
|
+
.select('run_sequence')
|
|
891
|
+
.eq('subagent_id', subagentId)
|
|
892
|
+
.order('run_sequence', { ascending: false })
|
|
893
|
+
.limit(1)
|
|
894
|
+
.maybeSingle();
|
|
895
|
+
const lastSequence = data && typeof data.run_sequence === 'number' ? data.run_sequence : 0;
|
|
896
|
+
return lastSequence + 1;
|
|
897
|
+
}
|
|
898
|
+
function mergeMetadata(base, update) {
|
|
899
|
+
return {
|
|
900
|
+
...(base ?? {}),
|
|
901
|
+
...update,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function asJson(value) {
|
|
905
|
+
return value;
|
|
906
|
+
}
|
|
907
|
+
function requestSubagentCancel(subagentId, runId, reason) {
|
|
908
|
+
const activeRun = activeSubagentRuns.get(subagentId);
|
|
909
|
+
if (activeRun && (!runId || activeRun.runId === runId)) {
|
|
910
|
+
activeRun.cancelled = true;
|
|
911
|
+
activeRun.cancelReason = reason;
|
|
912
|
+
if (activeRun.child) {
|
|
913
|
+
activeRun.child.kill('SIGTERM');
|
|
914
|
+
setTimeout(() => {
|
|
915
|
+
if (!activeRun.child?.killed) {
|
|
916
|
+
activeRun.child?.kill('SIGKILL');
|
|
917
|
+
}
|
|
918
|
+
}, SUBAGENT_CANCEL_KILL_TIMEOUT_MS);
|
|
919
|
+
}
|
|
920
|
+
return { active: true };
|
|
921
|
+
}
|
|
922
|
+
if (runId) {
|
|
923
|
+
pendingCancelByRunId.set(runId, { reason });
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
pendingCancelBySubagentId.set(subagentId, { reason });
|
|
927
|
+
}
|
|
928
|
+
return { active: false };
|
|
929
|
+
}
|
|
930
|
+
function resolvePendingCancel(subagentId, runId, metadata) {
|
|
931
|
+
const cancelRunId = typeof metadata.cancel_run_id === 'string' ? metadata.cancel_run_id : null;
|
|
932
|
+
if (cancelRunId && cancelRunId === runId) {
|
|
933
|
+
return typeof metadata.cancel_reason === 'string'
|
|
934
|
+
? metadata.cancel_reason
|
|
935
|
+
: 'Cancelled';
|
|
936
|
+
}
|
|
937
|
+
const pendingByRun = pendingCancelByRunId.get(runId);
|
|
938
|
+
if (pendingByRun)
|
|
939
|
+
return pendingByRun.reason;
|
|
940
|
+
const pendingBySubagent = pendingCancelBySubagentId.get(subagentId);
|
|
941
|
+
if (pendingBySubagent)
|
|
942
|
+
return pendingBySubagent.reason;
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
function clearPendingCancel(subagentId, runId) {
|
|
946
|
+
pendingCancelByRunId.delete(runId);
|
|
947
|
+
pendingCancelBySubagentId.delete(subagentId);
|
|
948
|
+
}
|
|
949
|
+
function buildErrorOutput(message) {
|
|
950
|
+
return {
|
|
951
|
+
schema_version: SUBAGENT_SCHEMA_VERSION,
|
|
952
|
+
content: message,
|
|
953
|
+
content_type: 'text',
|
|
954
|
+
errors: [message],
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function isJobTargeted(job, gatewayId) {
|
|
958
|
+
return !job.gateway_id || job.gateway_id === gatewayId;
|
|
959
|
+
}
|
|
960
|
+
async function sendHeartbeat(supabase, status, capabilities, deviceName) {
|
|
961
|
+
const { error } = await supabase.functions.invoke('gateway-heartbeat', {
|
|
962
|
+
body: {
|
|
963
|
+
status,
|
|
964
|
+
capabilities,
|
|
965
|
+
device_name: deviceName,
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
if (error) {
|
|
969
|
+
logError('Failed to send gateway heartbeat', { error: error.message });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
async function claimJob(supabase, config, job) {
|
|
973
|
+
if (!isJobTargeted(job, config.gatewayId)) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
const { data, error } = await supabase
|
|
977
|
+
.from('gateway_jobs')
|
|
978
|
+
.update({
|
|
979
|
+
status: 'running',
|
|
980
|
+
gateway_id: config.gatewayId,
|
|
981
|
+
started_at: new Date().toISOString(),
|
|
982
|
+
})
|
|
983
|
+
.eq('id', job.id)
|
|
984
|
+
.eq('status', 'queued')
|
|
985
|
+
.or(`gateway_id.is.null,gateway_id.eq.${config.gatewayId}`)
|
|
986
|
+
.select('*')
|
|
987
|
+
.maybeSingle();
|
|
988
|
+
if (error) {
|
|
989
|
+
logError('Failed to claim gateway job', { error: error.message, jobId: job.id });
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
if (!data) {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
return data;
|
|
996
|
+
}
|
|
997
|
+
async function completeJob(supabase, jobId, status, result, errorMessage) {
|
|
998
|
+
const updates = {
|
|
999
|
+
status,
|
|
1000
|
+
result: result ? asJson(result) : null,
|
|
1001
|
+
error: errorMessage || null,
|
|
1002
|
+
completed_at: new Date().toISOString(),
|
|
1003
|
+
};
|
|
1004
|
+
const { error } = await supabase
|
|
1005
|
+
.from('gateway_jobs')
|
|
1006
|
+
.update(updates)
|
|
1007
|
+
.eq('id', jobId)
|
|
1008
|
+
.eq('status', 'running');
|
|
1009
|
+
if (error) {
|
|
1010
|
+
logError('Failed to update gateway job result', { error: error.message, jobId });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
async function updateJobDebug(supabase, jobId, debug) {
|
|
1014
|
+
const { error } = await supabase
|
|
1015
|
+
.from('gateway_jobs')
|
|
1016
|
+
.update({ debug: asJson(debug) })
|
|
1017
|
+
.eq('id', jobId);
|
|
1018
|
+
if (error) {
|
|
1019
|
+
logError('Failed to update gateway job debug info', { error: error.message, jobId });
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function handleDiagnosticJob() {
|
|
1023
|
+
const output = await execClaudeVersion();
|
|
1024
|
+
if (!output.ok) {
|
|
1025
|
+
return {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
result: {
|
|
1028
|
+
command: `${CLAUDE_COMMAND} --version`,
|
|
1029
|
+
stdout: output.stdout,
|
|
1030
|
+
stderr: output.stderr,
|
|
1031
|
+
exit_code: output.exitCode,
|
|
1032
|
+
duration_ms: output.durationMs,
|
|
1033
|
+
error: output.error,
|
|
1034
|
+
},
|
|
1035
|
+
error: output.error || 'Claude CLI check failed',
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
ok: true,
|
|
1040
|
+
result: {
|
|
1041
|
+
command: `${CLAUDE_COMMAND} --version`,
|
|
1042
|
+
stdout: output.stdout,
|
|
1043
|
+
stderr: output.stderr,
|
|
1044
|
+
exit_code: output.exitCode,
|
|
1045
|
+
duration_ms: output.durationMs,
|
|
1046
|
+
version: output.version,
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
async function handleSubagentCancelJob(supabase, job) {
|
|
1051
|
+
const payload = job.payload ?? {};
|
|
1052
|
+
const subagentId = typeof payload.subagent_id === 'string'
|
|
1053
|
+
? payload.subagent_id
|
|
1054
|
+
: typeof payload.subagentId === 'string'
|
|
1055
|
+
? payload.subagentId
|
|
1056
|
+
: null;
|
|
1057
|
+
if (!subagentId) {
|
|
1058
|
+
return {
|
|
1059
|
+
ok: false,
|
|
1060
|
+
result: { message: 'Missing subagent_id in payload' },
|
|
1061
|
+
error: 'Missing subagent_id in payload',
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const runId = typeof payload.run_id === 'string'
|
|
1065
|
+
? payload.run_id
|
|
1066
|
+
: typeof payload.runId === 'string'
|
|
1067
|
+
? payload.runId
|
|
1068
|
+
: null;
|
|
1069
|
+
const reason = typeof payload.reason === 'string' && payload.reason.trim().length > 0
|
|
1070
|
+
? payload.reason.trim()
|
|
1071
|
+
: 'Cancelled';
|
|
1072
|
+
const { data: subagent } = await supabase
|
|
1073
|
+
.from('subagents')
|
|
1074
|
+
.select('status, metadata, input')
|
|
1075
|
+
.eq('id', subagentId)
|
|
1076
|
+
.maybeSingle();
|
|
1077
|
+
if (!subagent || subagent.status !== 'running') {
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
result: {
|
|
1081
|
+
subagent_id: subagentId,
|
|
1082
|
+
run_id: runId,
|
|
1083
|
+
cancelled_active_run: false,
|
|
1084
|
+
reason,
|
|
1085
|
+
message: 'Subagent not running',
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
const metadata = (subagent.metadata ?? {});
|
|
1090
|
+
const input = (subagent.input ?? {});
|
|
1091
|
+
const resolvedRunId = runId ||
|
|
1092
|
+
(typeof metadata.current_run_id === 'string' ? metadata.current_run_id : null) ||
|
|
1093
|
+
(typeof input.run_id === 'string' ? input.run_id : null);
|
|
1094
|
+
const { active } = requestSubagentCancel(subagentId, resolvedRunId, reason);
|
|
1095
|
+
if (resolvedRunId) {
|
|
1096
|
+
const nowIso = new Date().toISOString();
|
|
1097
|
+
const cancelledOutput = buildErrorOutput(reason);
|
|
1098
|
+
const { data: runningRun, error: runningRunError } = await supabase
|
|
1099
|
+
.from('subagent_runs')
|
|
1100
|
+
.select('id, status')
|
|
1101
|
+
.eq('id', resolvedRunId)
|
|
1102
|
+
.eq('status', 'running')
|
|
1103
|
+
.maybeSingle();
|
|
1104
|
+
if (runningRunError) {
|
|
1105
|
+
logError('Failed to fetch running subagent run for cancel', {
|
|
1106
|
+
error: runningRunError.message,
|
|
1107
|
+
subagentId,
|
|
1108
|
+
runId: resolvedRunId,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
if (runningRun?.id) {
|
|
1112
|
+
const { error: runUpdateError } = await supabase
|
|
1113
|
+
.from('subagent_runs')
|
|
1114
|
+
.update({
|
|
1115
|
+
status: 'cancelled',
|
|
1116
|
+
output: asJson(cancelledOutput),
|
|
1117
|
+
error: reason,
|
|
1118
|
+
completed_at: nowIso,
|
|
1119
|
+
})
|
|
1120
|
+
.eq('id', resolvedRunId);
|
|
1121
|
+
if (runUpdateError) {
|
|
1122
|
+
logError('Failed to mark subagent run as cancelled', {
|
|
1123
|
+
error: runUpdateError.message,
|
|
1124
|
+
subagentId,
|
|
1125
|
+
runId: resolvedRunId,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const { error: subagentUpdateError } = await supabase
|
|
1129
|
+
.from('subagents')
|
|
1130
|
+
.update({
|
|
1131
|
+
status: 'failed',
|
|
1132
|
+
output: asJson(cancelledOutput),
|
|
1133
|
+
error: reason,
|
|
1134
|
+
completed_at: nowIso,
|
|
1135
|
+
metadata: asJson(mergeMetadata(metadata, {
|
|
1136
|
+
cancel_run_id: resolvedRunId,
|
|
1137
|
+
cancel_requested_at: nowIso,
|
|
1138
|
+
cancel_reason: reason,
|
|
1139
|
+
})),
|
|
1140
|
+
})
|
|
1141
|
+
.eq('id', subagentId);
|
|
1142
|
+
if (subagentUpdateError) {
|
|
1143
|
+
logError('Failed to mark subagent as cancelled', {
|
|
1144
|
+
error: subagentUpdateError.message,
|
|
1145
|
+
subagentId,
|
|
1146
|
+
runId: resolvedRunId,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
ok: true,
|
|
1153
|
+
result: {
|
|
1154
|
+
subagent_id: subagentId,
|
|
1155
|
+
run_id: resolvedRunId,
|
|
1156
|
+
cancelled_active_run: active,
|
|
1157
|
+
reason,
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
async function handleSubagentRunJob(supabase, job) {
|
|
1162
|
+
const payload = job.payload ?? {};
|
|
1163
|
+
const subagentId = typeof payload.subagent_id === 'string'
|
|
1164
|
+
? payload.subagent_id
|
|
1165
|
+
: typeof payload.subagentId === 'string'
|
|
1166
|
+
? payload.subagentId
|
|
1167
|
+
: null;
|
|
1168
|
+
if (!subagentId) {
|
|
1169
|
+
return {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
result: { message: 'Missing subagent_id in payload' },
|
|
1172
|
+
error: 'Missing subagent_id in payload',
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const { data: subagent, error: subagentError } = await supabase
|
|
1176
|
+
.from('subagents')
|
|
1177
|
+
.select('id, team_id, gateway_id, subagent_type, status, config, input, metadata')
|
|
1178
|
+
.eq('id', subagentId)
|
|
1179
|
+
.maybeSingle();
|
|
1180
|
+
if (subagentError || !subagent) {
|
|
1181
|
+
return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
result: { message: 'Subagent not found', subagent_id: subagentId },
|
|
1184
|
+
error: subagentError?.message ?? 'Subagent not found',
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
const subagentType = subagent.subagent_type;
|
|
1188
|
+
const adapter = getSubagentAdapter(subagentType);
|
|
1189
|
+
if (!adapter) {
|
|
1190
|
+
return {
|
|
1191
|
+
ok: false,
|
|
1192
|
+
result: { message: 'Unsupported subagent type', subagent_type: subagentType },
|
|
1193
|
+
error: `Unsupported subagent type: ${subagentType}`,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
logInfo('Processing subagent run', {
|
|
1197
|
+
jobId: job.id,
|
|
1198
|
+
subagentId: subagentId,
|
|
1199
|
+
subagentType,
|
|
1200
|
+
provider: inferProviderFromSubagentType(subagentType),
|
|
1201
|
+
});
|
|
1202
|
+
const prompt = extractSubagentPrompt((subagent.input ?? {}));
|
|
1203
|
+
if (!prompt) {
|
|
1204
|
+
return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
result: { message: 'Missing prompt in subagent input', subagent_id: subagentId },
|
|
1207
|
+
error: 'Missing prompt in subagent input',
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
const configPayload = (subagent.config ?? {});
|
|
1211
|
+
const metadata = (subagent.metadata ?? {});
|
|
1212
|
+
const inputPayload = (subagent.input ?? {});
|
|
1213
|
+
const inputRunId = typeof inputPayload.run_id === 'string' ? inputPayload.run_id.trim() : null;
|
|
1214
|
+
const rawRunSequence = typeof inputPayload.run_sequence === 'number'
|
|
1215
|
+
? inputPayload.run_sequence
|
|
1216
|
+
: typeof inputPayload.runSequence === 'number'
|
|
1217
|
+
? inputPayload.runSequence
|
|
1218
|
+
: typeof inputPayload.run_sequence === 'string'
|
|
1219
|
+
? Number.parseInt(inputPayload.run_sequence, 10)
|
|
1220
|
+
: typeof inputPayload.runSequence === 'string'
|
|
1221
|
+
? Number.parseInt(inputPayload.runSequence, 10)
|
|
1222
|
+
: NaN;
|
|
1223
|
+
const inputRunSequence = Number.isFinite(rawRunSequence) ? rawRunSequence : null;
|
|
1224
|
+
const resumeSessionId = typeof metadata.session_id === 'string' ? metadata.session_id.trim() : null;
|
|
1225
|
+
const retryConfig = resolveSubagentRetryConfig(configPayload);
|
|
1226
|
+
const retryReasons = [];
|
|
1227
|
+
if (resumeSessionId && !adapter.supportsContinuation) {
|
|
1228
|
+
return {
|
|
1229
|
+
ok: false,
|
|
1230
|
+
result: { message: 'Subagent does not support continuation', subagent_id: subagentId },
|
|
1231
|
+
error: 'Subagent provider does not support continuation',
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
const resolvedRunId = inputRunId && isUuid(inputRunId) ? inputRunId : randomUUID();
|
|
1235
|
+
const nowIso = new Date().toISOString();
|
|
1236
|
+
const runSequence = inputRunSequence ?? (await getNextRunSequence(supabase, subagentId));
|
|
1237
|
+
const runMetadata = {
|
|
1238
|
+
schema_version: SUBAGENT_SCHEMA_VERSION,
|
|
1239
|
+
runner: 'gateway',
|
|
1240
|
+
adapter_id: adapter.id,
|
|
1241
|
+
subagent_type: subagentType,
|
|
1242
|
+
run_id: resolvedRunId,
|
|
1243
|
+
run_sequence: runSequence,
|
|
1244
|
+
};
|
|
1245
|
+
const pendingCancelReason = resolvePendingCancel(subagentId, resolvedRunId, metadata);
|
|
1246
|
+
if (pendingCancelReason) {
|
|
1247
|
+
clearPendingCancel(subagentId, resolvedRunId);
|
|
1248
|
+
const nowIso = new Date().toISOString();
|
|
1249
|
+
const cancelledOutput = buildErrorOutput(pendingCancelReason);
|
|
1250
|
+
const { error: cancelInsertError } = await supabase.from('subagent_runs').insert({
|
|
1251
|
+
id: resolvedRunId,
|
|
1252
|
+
subagent_id: subagentId,
|
|
1253
|
+
team_id: subagent.team_id,
|
|
1254
|
+
gateway_id: subagent.gateway_id,
|
|
1255
|
+
run_sequence: runSequence,
|
|
1256
|
+
status: 'cancelled',
|
|
1257
|
+
prompt,
|
|
1258
|
+
output: asJson(cancelledOutput),
|
|
1259
|
+
error: pendingCancelReason,
|
|
1260
|
+
started_at: nowIso,
|
|
1261
|
+
completed_at: nowIso,
|
|
1262
|
+
metadata: asJson({
|
|
1263
|
+
...runMetadata,
|
|
1264
|
+
cancel_reason: pendingCancelReason,
|
|
1265
|
+
}),
|
|
1266
|
+
});
|
|
1267
|
+
if (cancelInsertError) {
|
|
1268
|
+
return {
|
|
1269
|
+
ok: false,
|
|
1270
|
+
result: { message: 'Failed to record cancelled run', subagent_id: subagentId },
|
|
1271
|
+
error: cancelInsertError.message,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
ok: true,
|
|
1276
|
+
result: {
|
|
1277
|
+
subagent_id: subagentId,
|
|
1278
|
+
status: 'cancelled',
|
|
1279
|
+
output: cancelledOutput,
|
|
1280
|
+
error: pendingCancelReason,
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
const { error: runInsertError } = await supabase.from('subagent_runs').insert({
|
|
1285
|
+
id: resolvedRunId,
|
|
1286
|
+
subagent_id: subagentId,
|
|
1287
|
+
team_id: subagent.team_id,
|
|
1288
|
+
gateway_id: subagent.gateway_id,
|
|
1289
|
+
run_sequence: runSequence,
|
|
1290
|
+
status: 'running',
|
|
1291
|
+
prompt,
|
|
1292
|
+
started_at: nowIso,
|
|
1293
|
+
metadata: asJson(runMetadata),
|
|
1294
|
+
});
|
|
1295
|
+
if (runInsertError) {
|
|
1296
|
+
return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
result: { message: 'Failed to create subagent run', subagent_id: subagentId },
|
|
1299
|
+
error: runInsertError.message,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
const activeRun = {
|
|
1303
|
+
runId: resolvedRunId,
|
|
1304
|
+
cancelled: false,
|
|
1305
|
+
};
|
|
1306
|
+
activeSubagentRuns.set(subagentId, activeRun);
|
|
1307
|
+
await supabase
|
|
1308
|
+
.from('subagents')
|
|
1309
|
+
.update({
|
|
1310
|
+
status: 'running',
|
|
1311
|
+
started_at: nowIso,
|
|
1312
|
+
metadata: asJson(mergeMetadata(metadata, {
|
|
1313
|
+
current_run_id: resolvedRunId,
|
|
1314
|
+
current_run_sequence: runSequence,
|
|
1315
|
+
})),
|
|
1316
|
+
})
|
|
1317
|
+
.eq('id', subagentId);
|
|
1318
|
+
const failRun = async (message) => {
|
|
1319
|
+
const failureOutput = buildErrorOutput(message);
|
|
1320
|
+
const completedAt = new Date().toISOString();
|
|
1321
|
+
await supabase
|
|
1322
|
+
.from('subagent_runs')
|
|
1323
|
+
.update({
|
|
1324
|
+
status: 'failed',
|
|
1325
|
+
output: asJson(failureOutput),
|
|
1326
|
+
error: message,
|
|
1327
|
+
completed_at: completedAt,
|
|
1328
|
+
})
|
|
1329
|
+
.eq('id', resolvedRunId);
|
|
1330
|
+
await supabase
|
|
1331
|
+
.from('subagents')
|
|
1332
|
+
.update({
|
|
1333
|
+
status: 'failed',
|
|
1334
|
+
output: asJson(failureOutput),
|
|
1335
|
+
error: message,
|
|
1336
|
+
completed_at: completedAt,
|
|
1337
|
+
})
|
|
1338
|
+
.eq('id', subagentId);
|
|
1339
|
+
activeSubagentRuns.delete(subagentId);
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
result: { message, subagent_id: subagentId },
|
|
1343
|
+
error: message,
|
|
1344
|
+
};
|
|
1345
|
+
};
|
|
1346
|
+
let runPlan;
|
|
1347
|
+
try {
|
|
1348
|
+
const adapterId = adapter.id;
|
|
1349
|
+
const providerSupport = PROVIDER_CAPABILITIES?.[adapterId]?.supported_flags ??
|
|
1350
|
+
(adapterId === 'claude_code'
|
|
1351
|
+
? toFlagRecord(CLAUDE_SUPPORT ?? DEFAULT_CLAUDE_SUPPORT)
|
|
1352
|
+
: {});
|
|
1353
|
+
const command = adapterId === 'codex'
|
|
1354
|
+
? CODEX_COMMAND
|
|
1355
|
+
: adapterId === 'gemini'
|
|
1356
|
+
? GEMINI_COMMAND
|
|
1357
|
+
: CLAUDE_COMMAND;
|
|
1358
|
+
runPlan = adapter.buildRunPlan({
|
|
1359
|
+
prompt,
|
|
1360
|
+
config: configPayload,
|
|
1361
|
+
command,
|
|
1362
|
+
support: providerSupport,
|
|
1363
|
+
resumeSessionId,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
const message = error instanceof Error ? error.message : 'Failed to build subagent run plan';
|
|
1368
|
+
return await failRun(message);
|
|
1369
|
+
}
|
|
1370
|
+
if (runPlan.outputFormat === 'stream-json') {
|
|
1371
|
+
if (!adapter.normalizeStreamingResult || !adapter.normalizeStreamEvents) {
|
|
1372
|
+
return await failRun('Subagent adapter does not support normalized streaming output');
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
const workDir = await ensureSubagentWorkDir(subagentId);
|
|
1376
|
+
if (runPlan.files && runPlan.files.length > 0) {
|
|
1377
|
+
for (const file of runPlan.files) {
|
|
1378
|
+
const filePath = path.join(workDir, file.path);
|
|
1379
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1380
|
+
await fs.writeFile(filePath, file.contents, 'utf-8');
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const resolvedEnv = runPlan.env
|
|
1384
|
+
? Object.fromEntries(Object.entries(runPlan.env).map(([key, value]) => {
|
|
1385
|
+
if (typeof value === 'string' && value.includes('{WORKDIR}')) {
|
|
1386
|
+
return [key, value.replace(/\{WORKDIR\}/g, workDir)];
|
|
1387
|
+
}
|
|
1388
|
+
return [key, value];
|
|
1389
|
+
}))
|
|
1390
|
+
: undefined;
|
|
1391
|
+
let runResult = null;
|
|
1392
|
+
let streamResult = null;
|
|
1393
|
+
let activeResult = null;
|
|
1394
|
+
let normalized = null;
|
|
1395
|
+
let attempt = 0;
|
|
1396
|
+
while (attempt < retryConfig.maxAttempts) {
|
|
1397
|
+
attempt += 1;
|
|
1398
|
+
runResult = null;
|
|
1399
|
+
streamResult = null;
|
|
1400
|
+
activeResult = null;
|
|
1401
|
+
normalized = null;
|
|
1402
|
+
let executionError = null;
|
|
1403
|
+
try {
|
|
1404
|
+
if (runPlan.outputFormat === 'stream-json') {
|
|
1405
|
+
streamResult = await runSubagentCommandStreaming(runPlan.command, runPlan.args, {
|
|
1406
|
+
timeoutMs: runPlan.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS,
|
|
1407
|
+
cwd: workDir,
|
|
1408
|
+
env: resolvedEnv,
|
|
1409
|
+
onStart: (child) => {
|
|
1410
|
+
activeRun.child = child;
|
|
1411
|
+
if (activeRun.cancelled) {
|
|
1412
|
+
child.kill('SIGTERM');
|
|
1413
|
+
setTimeout(() => {
|
|
1414
|
+
if (!child.killed) {
|
|
1415
|
+
child.kill('SIGKILL');
|
|
1416
|
+
}
|
|
1417
|
+
}, SUBAGENT_CANCEL_KILL_TIMEOUT_MS);
|
|
1418
|
+
}
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
|
|
1424
|
+
timeoutMs: runPlan.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS,
|
|
1425
|
+
cwd: workDir,
|
|
1426
|
+
env: resolvedEnv,
|
|
1427
|
+
onStart: (child) => {
|
|
1428
|
+
activeRun.child = child;
|
|
1429
|
+
if (activeRun.cancelled) {
|
|
1430
|
+
child.kill('SIGTERM');
|
|
1431
|
+
setTimeout(() => {
|
|
1432
|
+
if (!child.killed) {
|
|
1433
|
+
child.kill('SIGKILL');
|
|
1434
|
+
}
|
|
1435
|
+
}, SUBAGENT_CANCEL_KILL_TIMEOUT_MS);
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
executionError = error instanceof Error ? error.message : 'Subagent run failed';
|
|
1443
|
+
}
|
|
1444
|
+
activeResult =
|
|
1445
|
+
streamResult ??
|
|
1446
|
+
runResult ?? {
|
|
1447
|
+
ok: false,
|
|
1448
|
+
stdout: '',
|
|
1449
|
+
stderr: executionError ?? '',
|
|
1450
|
+
exitCode: null,
|
|
1451
|
+
durationMs: 0,
|
|
1452
|
+
timedOut: false,
|
|
1453
|
+
stdoutTruncated: false,
|
|
1454
|
+
stderrTruncated: false,
|
|
1455
|
+
error: executionError ?? 'Subagent run failed',
|
|
1456
|
+
};
|
|
1457
|
+
if (streamResult || runResult) {
|
|
1458
|
+
normalized =
|
|
1459
|
+
runPlan.outputFormat === 'stream-json' && adapter.normalizeStreamingResult
|
|
1460
|
+
? adapter.normalizeStreamingResult({
|
|
1461
|
+
events: streamResult?.events ?? [],
|
|
1462
|
+
rawOutput: streamResult?.stdout ?? '',
|
|
1463
|
+
})
|
|
1464
|
+
: adapter.normalizeRunResult({
|
|
1465
|
+
stdout: runResult?.stdout ?? '',
|
|
1466
|
+
outputFormat: runPlan.outputFormat === 'stream-json' ? 'json' : runPlan.outputFormat,
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
normalized = {
|
|
1471
|
+
output: buildErrorOutput(executionError ?? 'Subagent run failed'),
|
|
1472
|
+
metadata: {},
|
|
1473
|
+
parseError: null,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
const parseStrict = runPlan.outputFormat === 'json';
|
|
1477
|
+
const parseError = normalized.parseError ?? null;
|
|
1478
|
+
const retryDecision = classifySubagentRetryable({
|
|
1479
|
+
result: {
|
|
1480
|
+
ok: activeResult.ok,
|
|
1481
|
+
timedOut: activeResult.timedOut,
|
|
1482
|
+
exitCode: activeResult.exitCode,
|
|
1483
|
+
error: activeResult.error,
|
|
1484
|
+
stderr: activeResult.stderr,
|
|
1485
|
+
stdoutTruncated: activeResult.stdoutTruncated,
|
|
1486
|
+
stderrTruncated: activeResult.stderrTruncated,
|
|
1487
|
+
},
|
|
1488
|
+
parseError,
|
|
1489
|
+
parseStrict,
|
|
1490
|
+
config: retryConfig,
|
|
1491
|
+
});
|
|
1492
|
+
if (retryDecision &&
|
|
1493
|
+
attempt < retryConfig.maxAttempts &&
|
|
1494
|
+
!activeRun.cancelled) {
|
|
1495
|
+
retryReasons.push(retryDecision.reason);
|
|
1496
|
+
const delayMs = computeRetryDelayMs(attempt, retryConfig.backoffMs, retryConfig.maxBackoffMs);
|
|
1497
|
+
logInfo('Retrying subagent run', {
|
|
1498
|
+
subagentId,
|
|
1499
|
+
runId: resolvedRunId,
|
|
1500
|
+
attempt,
|
|
1501
|
+
maxAttempts: retryConfig.maxAttempts,
|
|
1502
|
+
reason: retryDecision.reason,
|
|
1503
|
+
delay_ms: delayMs,
|
|
1504
|
+
});
|
|
1505
|
+
await sleep(delayMs);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
if (!activeResult || !normalized) {
|
|
1511
|
+
return await failRun('Subagent run failed');
|
|
1512
|
+
}
|
|
1513
|
+
const normalizedResult = normalized;
|
|
1514
|
+
const { data: cancellationRow, error: cancellationError } = await supabase
|
|
1515
|
+
.from('subagent_runs')
|
|
1516
|
+
.select('status')
|
|
1517
|
+
.eq('id', resolvedRunId)
|
|
1518
|
+
.maybeSingle();
|
|
1519
|
+
if (cancellationError) {
|
|
1520
|
+
logError('Failed to check subagent run cancellation', {
|
|
1521
|
+
error: cancellationError.message,
|
|
1522
|
+
subagentId,
|
|
1523
|
+
runId: resolvedRunId,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
const dbCancelled = cancellationRow?.status === 'cancelled';
|
|
1527
|
+
const wasCancelled = activeRun.cancelled || dbCancelled;
|
|
1528
|
+
const cancelReason = activeRun.cancelReason && activeRun.cancelReason.trim().length > 0
|
|
1529
|
+
? activeRun.cancelReason
|
|
1530
|
+
: 'Cancelled';
|
|
1531
|
+
const output = normalizedResult.output;
|
|
1532
|
+
const parseError = normalizedResult.parseError ?? null;
|
|
1533
|
+
const retryCount = Math.max(0, attempt - 1);
|
|
1534
|
+
const completedAt = new Date().toISOString();
|
|
1535
|
+
const parseStrict = runPlan.outputFormat === 'json';
|
|
1536
|
+
const shouldFail = !activeResult.ok || (parseStrict && !!parseError);
|
|
1537
|
+
const stderrMessage = activeResult.stderr && activeResult.stderr.trim().length > 0
|
|
1538
|
+
? activeResult.stderr.trim()
|
|
1539
|
+
: null;
|
|
1540
|
+
const exitCodeMessage = activeResult.exitCode === null
|
|
1541
|
+
? 'Subagent process did not exit cleanly'
|
|
1542
|
+
: `Subagent exited with code ${activeResult.exitCode}`;
|
|
1543
|
+
const errorMessage = !activeResult.ok
|
|
1544
|
+
? (activeResult.error ?? stderrMessage ?? exitCodeMessage)
|
|
1545
|
+
: parseError ?? null;
|
|
1546
|
+
const failureOutput = buildErrorOutput(wasCancelled ? cancelReason : errorMessage ?? 'Subagent run failed');
|
|
1547
|
+
const metadataUpdate = {
|
|
1548
|
+
schema_version: SUBAGENT_SCHEMA_VERSION,
|
|
1549
|
+
runner: 'gateway',
|
|
1550
|
+
adapter_id: adapter.id,
|
|
1551
|
+
subagent_type: subagentType,
|
|
1552
|
+
run_id: resolvedRunId,
|
|
1553
|
+
run_sequence: runSequence,
|
|
1554
|
+
last_run_at: completedAt,
|
|
1555
|
+
command: `${runPlan.command} ${runPlan.args.join(' ')}`,
|
|
1556
|
+
exit_code: activeResult.exitCode,
|
|
1557
|
+
duration_ms: activeResult.durationMs,
|
|
1558
|
+
timed_out: activeResult.timedOut,
|
|
1559
|
+
stdout_truncated: activeResult.stdoutTruncated,
|
|
1560
|
+
stderr_truncated: activeResult.stderrTruncated,
|
|
1561
|
+
output_format: runPlan.outputFormat,
|
|
1562
|
+
parse_error: parseError,
|
|
1563
|
+
retry_count: retryCount,
|
|
1564
|
+
retry_reasons: retryReasons,
|
|
1565
|
+
retry_max_attempts: retryConfig.maxAttempts,
|
|
1566
|
+
cancel_run_id: null,
|
|
1567
|
+
cancel_requested_at: null,
|
|
1568
|
+
cancel_reason: wasCancelled ? cancelReason : null,
|
|
1569
|
+
cancelled: wasCancelled,
|
|
1570
|
+
};
|
|
1571
|
+
if (activeResult.stderr) {
|
|
1572
|
+
metadataUpdate.stderr = activeResult.stderr;
|
|
1573
|
+
}
|
|
1574
|
+
const mergedMetadata = mergeMetadata(mergeMetadata((subagent.metadata ?? {}), normalized.metadata), metadataUpdate);
|
|
1575
|
+
const runMetadataUpdate = mergeMetadata(mergeMetadata(runMetadata, normalized.metadata), metadataUpdate);
|
|
1576
|
+
if (streamResult?.events?.length && adapter.normalizeStreamEvents) {
|
|
1577
|
+
const normalizedEvents = adapter.normalizeStreamEvents(streamResult.events);
|
|
1578
|
+
const eventRows = normalizedEvents.map((entry, index) => ({
|
|
1579
|
+
run_id: resolvedRunId,
|
|
1580
|
+
subagent_id: subagentId,
|
|
1581
|
+
team_id: subagent.team_id,
|
|
1582
|
+
gateway_id: subagent.gateway_id,
|
|
1583
|
+
sequence: index,
|
|
1584
|
+
event_type: entry.event_type,
|
|
1585
|
+
payload: asJson(trimEventPayload(entry.payload)),
|
|
1586
|
+
}));
|
|
1587
|
+
for (let i = 0; i < eventRows.length; i += SUBAGENT_EVENT_BATCH_SIZE) {
|
|
1588
|
+
const chunk = eventRows.slice(i, i + SUBAGENT_EVENT_BATCH_SIZE);
|
|
1589
|
+
const { error: eventsError } = await supabase
|
|
1590
|
+
.from('subagent_run_events')
|
|
1591
|
+
.insert(chunk);
|
|
1592
|
+
if (eventsError) {
|
|
1593
|
+
logError('Failed to insert subagent run events', {
|
|
1594
|
+
error: eventsError.message,
|
|
1595
|
+
subagentId,
|
|
1596
|
+
runId: resolvedRunId,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
await supabase
|
|
1602
|
+
.from('subagent_runs')
|
|
1603
|
+
.update({
|
|
1604
|
+
status: wasCancelled ? 'cancelled' : shouldFail ? 'failed' : 'completed',
|
|
1605
|
+
output: asJson(wasCancelled ? failureOutput : shouldFail ? failureOutput : output),
|
|
1606
|
+
error: wasCancelled ? cancelReason : shouldFail ? errorMessage : null,
|
|
1607
|
+
completed_at: completedAt,
|
|
1608
|
+
metadata: asJson(runMetadataUpdate),
|
|
1609
|
+
})
|
|
1610
|
+
.eq('id', resolvedRunId);
|
|
1611
|
+
const { error: subagentUpdateError } = await supabase
|
|
1612
|
+
.from('subagents')
|
|
1613
|
+
.update({
|
|
1614
|
+
status: wasCancelled ? 'failed' : shouldFail ? 'failed' : 'completed',
|
|
1615
|
+
output: asJson(wasCancelled ? failureOutput : shouldFail ? failureOutput : output),
|
|
1616
|
+
error: wasCancelled ? cancelReason : shouldFail ? errorMessage : null,
|
|
1617
|
+
completed_at: completedAt,
|
|
1618
|
+
metadata: asJson(mergedMetadata),
|
|
1619
|
+
})
|
|
1620
|
+
.eq('id', subagentId);
|
|
1621
|
+
if (subagentUpdateError) {
|
|
1622
|
+
logError('Failed to update subagent after run', {
|
|
1623
|
+
error: subagentUpdateError.message,
|
|
1624
|
+
subagentId,
|
|
1625
|
+
runId: resolvedRunId,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
activeSubagentRuns.delete(subagentId);
|
|
1629
|
+
return {
|
|
1630
|
+
ok: !shouldFail,
|
|
1631
|
+
result: {
|
|
1632
|
+
subagent_id: subagentId,
|
|
1633
|
+
status: shouldFail ? 'failed' : 'completed',
|
|
1634
|
+
output: shouldFail ? null : output,
|
|
1635
|
+
error: shouldFail ? errorMessage : null,
|
|
1636
|
+
},
|
|
1637
|
+
error: shouldFail ? errorMessage ?? 'Subagent run failed' : undefined,
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
async function handleModelRunJob(_supabase, job) {
|
|
1641
|
+
const payload = job.payload ?? {};
|
|
1642
|
+
const prompt = typeof payload.prompt === 'string' ? payload.prompt : null;
|
|
1643
|
+
const model = typeof payload.model === 'string' ? payload.model : null;
|
|
1644
|
+
const jsonSchema = payload.json_schema && typeof payload.json_schema === 'object'
|
|
1645
|
+
? payload.json_schema
|
|
1646
|
+
: null;
|
|
1647
|
+
const appendSystemPrompt = typeof payload.append_system_prompt === 'string' ? payload.append_system_prompt : null;
|
|
1648
|
+
const timeoutMs = typeof payload.timeout_ms === 'number' && payload.timeout_ms > 0
|
|
1649
|
+
? payload.timeout_ms
|
|
1650
|
+
: DEFAULT_MODEL_RUN_TIMEOUT_MS;
|
|
1651
|
+
if (!prompt || !model) {
|
|
1652
|
+
return {
|
|
1653
|
+
ok: false,
|
|
1654
|
+
result: { message: 'Missing prompt or model for gateway model run' },
|
|
1655
|
+
error: 'Missing prompt or model',
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
if (!jsonSchema) {
|
|
1659
|
+
return {
|
|
1660
|
+
ok: false,
|
|
1661
|
+
result: { message: 'Missing json_schema for gateway model run' },
|
|
1662
|
+
error: 'Missing json_schema',
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
const providerFromPayload = typeof payload.provider === 'string' ? payload.provider : null;
|
|
1666
|
+
const providerId = (providerFromPayload === 'claude_code' || providerFromPayload === 'codex' || providerFromPayload === 'gemini')
|
|
1667
|
+
? providerFromPayload
|
|
1668
|
+
: inferGatewayProviderFromModel(model);
|
|
1669
|
+
const provider = getGatewayCliProvider(providerId);
|
|
1670
|
+
let normalizedPrompt = prompt;
|
|
1671
|
+
let normalizedSchema = jsonSchema;
|
|
1672
|
+
if (provider.normalizeSchema) {
|
|
1673
|
+
const normalized = provider.normalizeSchema(jsonSchema);
|
|
1674
|
+
normalizedSchema = normalized.schema;
|
|
1675
|
+
if (normalized.promptSuffix) {
|
|
1676
|
+
normalizedPrompt = `${normalizedPrompt}\n\n${normalized.promptSuffix}`;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
const runPlan = provider.buildModelRunPlan({
|
|
1680
|
+
model,
|
|
1681
|
+
prompt: normalizedPrompt,
|
|
1682
|
+
jsonSchema: normalizedSchema,
|
|
1683
|
+
appendSystemPrompt: appendSystemPrompt ?? undefined,
|
|
1684
|
+
timeoutMs,
|
|
1685
|
+
});
|
|
1686
|
+
const workDir = await ensureSubagentWorkDir(job.id);
|
|
1687
|
+
if (runPlan.files && runPlan.files.length > 0) {
|
|
1688
|
+
for (const file of runPlan.files) {
|
|
1689
|
+
const filePath = path.join(workDir, file.path);
|
|
1690
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1691
|
+
await fs.writeFile(filePath, file.contents, 'utf-8');
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const resolvedEnv = runPlan.env
|
|
1695
|
+
? Object.fromEntries(Object.entries(runPlan.env).map(([key, value]) => {
|
|
1696
|
+
if (typeof value === 'string' && value.includes('{WORKDIR}')) {
|
|
1697
|
+
return [key, value.replace(/\{WORKDIR\}/g, workDir)];
|
|
1698
|
+
}
|
|
1699
|
+
return [key, value];
|
|
1700
|
+
}))
|
|
1701
|
+
: undefined;
|
|
1702
|
+
const commandLine = [runPlan.command, ...runPlan.args].join(' ');
|
|
1703
|
+
const debugInfo = {
|
|
1704
|
+
provider_id: providerId,
|
|
1705
|
+
command: runPlan.command,
|
|
1706
|
+
args: runPlan.args,
|
|
1707
|
+
command_line: commandLine,
|
|
1708
|
+
work_dir: workDir,
|
|
1709
|
+
prompt_chars: prompt.length,
|
|
1710
|
+
json_schema_chars: JSON.stringify(normalizedSchema).length,
|
|
1711
|
+
timeout_ms: timeoutMs,
|
|
1712
|
+
output_mode: runPlan.outputMode,
|
|
1713
|
+
output_path: runPlan.outputPath ?? null,
|
|
1714
|
+
};
|
|
1715
|
+
if (runPlan.files && runPlan.files.length > 0) {
|
|
1716
|
+
debugInfo.files = runPlan.files.map((file) => file.path);
|
|
1717
|
+
}
|
|
1718
|
+
await updateJobDebug(_supabase, job.id, debugInfo);
|
|
1719
|
+
const runResult = await runSubagentCommand(runPlan.command, runPlan.args, {
|
|
1720
|
+
timeoutMs: runPlan.timeoutMs ?? DEFAULT_MODEL_RUN_TIMEOUT_MS,
|
|
1721
|
+
cwd: workDir,
|
|
1722
|
+
env: resolvedEnv,
|
|
1723
|
+
});
|
|
1724
|
+
const stdoutInfo = truncateLog(runResult.stdout);
|
|
1725
|
+
const stderrInfo = truncateLog(runResult.stderr);
|
|
1726
|
+
const errorSummary = extractGatewayErrorSummary(stderrInfo.value);
|
|
1727
|
+
debugInfo.stdout = stdoutInfo.value;
|
|
1728
|
+
debugInfo.stdout_truncated = stdoutInfo.truncated;
|
|
1729
|
+
debugInfo.stdout_bytes = stdoutInfo.originalBytes;
|
|
1730
|
+
debugInfo.stderr = stderrInfo.value;
|
|
1731
|
+
debugInfo.stderr_truncated = stderrInfo.truncated;
|
|
1732
|
+
debugInfo.stderr_bytes = stderrInfo.originalBytes;
|
|
1733
|
+
debugInfo.exit_code = runResult.exitCode;
|
|
1734
|
+
debugInfo.duration_ms = runResult.durationMs;
|
|
1735
|
+
debugInfo.timed_out = runResult.timedOut;
|
|
1736
|
+
if (errorSummary) {
|
|
1737
|
+
debugInfo.error_summary = errorSummary;
|
|
1738
|
+
}
|
|
1739
|
+
await updateJobDebug(_supabase, job.id, debugInfo);
|
|
1740
|
+
if (!runResult.ok) {
|
|
1741
|
+
return {
|
|
1742
|
+
ok: false,
|
|
1743
|
+
result: {
|
|
1744
|
+
message: 'Gateway CLI failed for model run',
|
|
1745
|
+
exit_code: runResult.exitCode,
|
|
1746
|
+
stderr: stderrInfo.value,
|
|
1747
|
+
stdout: stdoutInfo.value,
|
|
1748
|
+
command: commandLine,
|
|
1749
|
+
error_summary: errorSummary ?? null,
|
|
1750
|
+
},
|
|
1751
|
+
error: runResult.error ?? 'Gateway model run failed',
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
let normalized;
|
|
1755
|
+
try {
|
|
1756
|
+
normalized = provider.normalizeModelRunResult({
|
|
1757
|
+
stdout: runResult.stdout,
|
|
1758
|
+
outputFormat: runPlan.outputFormat,
|
|
1759
|
+
outputMode: runPlan.outputMode,
|
|
1760
|
+
outputPath: runPlan.outputPath,
|
|
1761
|
+
workDir,
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
const message = error instanceof Error ? error.message : 'Failed to normalize gateway output';
|
|
1766
|
+
return {
|
|
1767
|
+
ok: false,
|
|
1768
|
+
result: { message, stdout: stdoutInfo.value, stderr: stderrInfo.value, command: commandLine },
|
|
1769
|
+
error: message,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
return {
|
|
1773
|
+
ok: true,
|
|
1774
|
+
result: {
|
|
1775
|
+
output: normalized.output,
|
|
1776
|
+
raw: normalized.raw,
|
|
1777
|
+
usage: normalized.usage,
|
|
1778
|
+
model: normalized.model,
|
|
1779
|
+
duration_ms: runResult.durationMs,
|
|
1780
|
+
exit_code: runResult.exitCode,
|
|
1781
|
+
stdout_truncated: runResult.stdoutTruncated,
|
|
1782
|
+
stderr_truncated: runResult.stderrTruncated,
|
|
1783
|
+
command: commandLine,
|
|
1784
|
+
},
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
async function failSubagentRun(supabase, job, errorMessage) {
|
|
1788
|
+
const payload = job.payload ?? {};
|
|
1789
|
+
const subagentId = typeof payload.subagent_id === 'string'
|
|
1790
|
+
? payload.subagent_id
|
|
1791
|
+
: typeof payload.subagentId === 'string'
|
|
1792
|
+
? payload.subagentId
|
|
1793
|
+
: null;
|
|
1794
|
+
if (!subagentId)
|
|
1795
|
+
return;
|
|
1796
|
+
const { data: subagent } = await supabase
|
|
1797
|
+
.from('subagents')
|
|
1798
|
+
.select('id, status, subagent_type, metadata, team_id, gateway_id')
|
|
1799
|
+
.eq('id', subagentId)
|
|
1800
|
+
.maybeSingle();
|
|
1801
|
+
if (!subagent)
|
|
1802
|
+
return;
|
|
1803
|
+
if (subagent.status !== 'running')
|
|
1804
|
+
return;
|
|
1805
|
+
const nowIso = new Date().toISOString();
|
|
1806
|
+
const metadataUpdate = {
|
|
1807
|
+
schema_version: SUBAGENT_SCHEMA_VERSION,
|
|
1808
|
+
runner: 'gateway',
|
|
1809
|
+
subagent_type: subagent.subagent_type,
|
|
1810
|
+
last_run_at: nowIso,
|
|
1811
|
+
gateway_error: errorMessage,
|
|
1812
|
+
};
|
|
1813
|
+
const mergedMetadata = mergeMetadata((subagent.metadata ?? {}), metadataUpdate);
|
|
1814
|
+
const failureOutput = buildErrorOutput(errorMessage);
|
|
1815
|
+
const { data: runningRun } = await supabase
|
|
1816
|
+
.from('subagent_runs')
|
|
1817
|
+
.select('id, run_sequence')
|
|
1818
|
+
.eq('subagent_id', subagent.id)
|
|
1819
|
+
.eq('status', 'running')
|
|
1820
|
+
.order('run_sequence', { ascending: false })
|
|
1821
|
+
.limit(1)
|
|
1822
|
+
.maybeSingle();
|
|
1823
|
+
if (runningRun?.id) {
|
|
1824
|
+
const { data: lastEvent } = await supabase
|
|
1825
|
+
.from('subagent_run_events')
|
|
1826
|
+
.select('sequence')
|
|
1827
|
+
.eq('run_id', runningRun.id)
|
|
1828
|
+
.order('sequence', { ascending: false })
|
|
1829
|
+
.limit(1)
|
|
1830
|
+
.maybeSingle();
|
|
1831
|
+
const nextSequence = lastEvent && typeof lastEvent.sequence === 'number' ? lastEvent.sequence + 1 : 0;
|
|
1832
|
+
await supabase
|
|
1833
|
+
.from('subagent_runs')
|
|
1834
|
+
.update({
|
|
1835
|
+
status: 'failed',
|
|
1836
|
+
error: errorMessage,
|
|
1837
|
+
output: asJson(failureOutput),
|
|
1838
|
+
completed_at: nowIso,
|
|
1839
|
+
metadata: asJson(mergedMetadata),
|
|
1840
|
+
})
|
|
1841
|
+
.eq('id', runningRun.id);
|
|
1842
|
+
if (subagent.gateway_id) {
|
|
1843
|
+
await supabase.from('subagent_run_events').insert({
|
|
1844
|
+
run_id: runningRun.id,
|
|
1845
|
+
subagent_id: subagent.id,
|
|
1846
|
+
team_id: subagent.team_id,
|
|
1847
|
+
gateway_id: subagent.gateway_id,
|
|
1848
|
+
sequence: nextSequence,
|
|
1849
|
+
event_type: 'run_failed',
|
|
1850
|
+
payload: asJson({ error: errorMessage }),
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
const { error } = await supabase
|
|
1855
|
+
.from('subagents')
|
|
1856
|
+
.update({
|
|
1857
|
+
status: 'failed',
|
|
1858
|
+
error: errorMessage,
|
|
1859
|
+
completed_at: nowIso,
|
|
1860
|
+
output: asJson(failureOutput),
|
|
1861
|
+
metadata: asJson(mergedMetadata),
|
|
1862
|
+
})
|
|
1863
|
+
.eq('id', subagent.id);
|
|
1864
|
+
if (error) {
|
|
1865
|
+
logError('Failed to mark subagent as failed after gateway error', {
|
|
1866
|
+
subagentId,
|
|
1867
|
+
error: error.message,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
async function processJob(supabase, config, job) {
|
|
1872
|
+
const claimed = await claimJob(supabase, config, job);
|
|
1873
|
+
if (!claimed) {
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const jobDetails = describeGatewayJob(claimed);
|
|
1877
|
+
logInfo('Processing gateway job', {
|
|
1878
|
+
jobId: claimed.id,
|
|
1879
|
+
jobType: claimed.job_type,
|
|
1880
|
+
jobKind: jobDetails.job_kind,
|
|
1881
|
+
provider: jobDetails.provider ?? undefined,
|
|
1882
|
+
model: jobDetails.model ?? undefined,
|
|
1883
|
+
subagentType: jobDetails.subagent_type ?? undefined,
|
|
1884
|
+
});
|
|
1885
|
+
if (claimed.job_type === 'diagnostic') {
|
|
1886
|
+
const diagnostic = await handleDiagnosticJob();
|
|
1887
|
+
if (diagnostic.ok) {
|
|
1888
|
+
await completeJob(supabase, claimed.id, 'completed', diagnostic.result);
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
await completeJob(supabase, claimed.id, 'failed', diagnostic.result, diagnostic.error);
|
|
1892
|
+
}
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (claimed.job_type === 'subagent_run') {
|
|
1896
|
+
const result = await handleSubagentRunJob(supabase, claimed);
|
|
1897
|
+
if (result.ok) {
|
|
1898
|
+
await completeJob(supabase, claimed.id, 'completed', result.result);
|
|
1899
|
+
}
|
|
1900
|
+
else {
|
|
1901
|
+
await completeJob(supabase, claimed.id, 'failed', result.result, result.error);
|
|
1902
|
+
}
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
if (claimed.job_type === 'model_run') {
|
|
1906
|
+
const result = await handleModelRunJob(supabase, claimed);
|
|
1907
|
+
if (result.ok) {
|
|
1908
|
+
await completeJob(supabase, claimed.id, 'completed', result.result);
|
|
1909
|
+
}
|
|
1910
|
+
else {
|
|
1911
|
+
await completeJob(supabase, claimed.id, 'failed', result.result, result.error);
|
|
1912
|
+
}
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (claimed.job_type === 'subagent_cancel') {
|
|
1916
|
+
const result = await handleSubagentCancelJob(supabase, claimed);
|
|
1917
|
+
if (result.ok) {
|
|
1918
|
+
await completeJob(supabase, claimed.id, 'completed', result.result);
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
await completeJob(supabase, claimed.id, 'failed', result.result, result.error);
|
|
1922
|
+
}
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
await completeJob(supabase, claimed.id, 'failed', {
|
|
1926
|
+
message: 'Unsupported job type',
|
|
1927
|
+
job_type: claimed.job_type,
|
|
1928
|
+
}, `Unsupported job type: ${claimed.job_type}`);
|
|
1929
|
+
}
|
|
1930
|
+
async function startGateway(options) {
|
|
1931
|
+
const daemonRequested = options.daemon === true || options.background === true;
|
|
1932
|
+
const foregroundRequested = options.foreground === true;
|
|
1933
|
+
if (daemonRequested && !foregroundRequested) {
|
|
1934
|
+
const existingPid = readPidFile();
|
|
1935
|
+
if (existingPid && isProcessAlive(existingPid)) {
|
|
1936
|
+
logInfo('Gateway already running', { pid: existingPid });
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
const entryPath = process.argv[1] || '';
|
|
1940
|
+
if (entryPath.endsWith('.ts')) {
|
|
1941
|
+
throw new Error('Daemon mode requires the built gateway. Run "pnpm gateway:start -- --daemon".');
|
|
1942
|
+
}
|
|
1943
|
+
await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
|
|
1944
|
+
const logHandle = await fs.open(LOG_PATH, 'a');
|
|
1945
|
+
const childArgs = [entryPath, 'start', '--foreground'];
|
|
1946
|
+
const deviceName = getStringOption(options, 'device-name') || process.env.PANORAMA_GATEWAY_DEVICE_NAME;
|
|
1947
|
+
if (deviceName) {
|
|
1948
|
+
childArgs.push('--device-name', deviceName);
|
|
1949
|
+
}
|
|
1950
|
+
const child = spawn(process.execPath, childArgs, {
|
|
1951
|
+
detached: true,
|
|
1952
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd],
|
|
1953
|
+
env: {
|
|
1954
|
+
...process.env,
|
|
1955
|
+
PANORAMA_GATEWAY_LOG_PATH: LOG_PATH,
|
|
1956
|
+
PANORAMA_GATEWAY_PID_PATH: PID_PATH,
|
|
1957
|
+
},
|
|
1958
|
+
});
|
|
1959
|
+
child.unref();
|
|
1960
|
+
await logHandle.close();
|
|
1961
|
+
if (!child.pid) {
|
|
1962
|
+
throw new Error('Failed to determine gateway process id');
|
|
1963
|
+
}
|
|
1964
|
+
await writePidFile(child.pid);
|
|
1965
|
+
logInfo('Gateway started in background', { pid: child.pid, logPath: LOG_PATH });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
let config;
|
|
1969
|
+
try {
|
|
1970
|
+
config = await loadConfig();
|
|
1971
|
+
}
|
|
1972
|
+
catch (error) {
|
|
1973
|
+
throw new Error(`Gateway config not found. Run "pair" first or set PANORAMA_GATEWAY_CONFIG_PATH. (${String(error)})`);
|
|
1974
|
+
}
|
|
1975
|
+
const supabaseUrl = resolveSupabaseUrl(options, config);
|
|
1976
|
+
const supabaseAnonKey = resolveSupabaseAnonKey(options, config);
|
|
1977
|
+
if (!supabaseUrl || !supabaseAnonKey) {
|
|
1978
|
+
throw new Error('Missing Supabase URL or anon key for gateway start');
|
|
1979
|
+
}
|
|
1980
|
+
const deviceName = getStringOption(options, 'device-name') ||
|
|
1981
|
+
config.deviceName ||
|
|
1982
|
+
process.env.PANORAMA_GATEWAY_DEVICE_NAME ||
|
|
1983
|
+
os.hostname();
|
|
1984
|
+
config = {
|
|
1985
|
+
...config,
|
|
1986
|
+
supabaseUrl,
|
|
1987
|
+
supabaseAnonKey,
|
|
1988
|
+
deviceName,
|
|
1989
|
+
};
|
|
1990
|
+
await saveConfig(config);
|
|
1991
|
+
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
|
1992
|
+
auth: {
|
|
1993
|
+
persistSession: false,
|
|
1994
|
+
autoRefreshToken: true,
|
|
1995
|
+
detectSessionInUrl: false,
|
|
1996
|
+
},
|
|
1997
|
+
realtime: {
|
|
1998
|
+
params: {
|
|
1999
|
+
eventsPerSecond: 10,
|
|
2000
|
+
},
|
|
2001
|
+
},
|
|
2002
|
+
});
|
|
2003
|
+
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
|
|
2004
|
+
access_token: config.accessToken,
|
|
2005
|
+
refresh_token: config.refreshToken,
|
|
2006
|
+
});
|
|
2007
|
+
if (sessionError || !sessionData?.session) {
|
|
2008
|
+
throw new Error(`Failed to authenticate gateway session: ${sessionError?.message}`);
|
|
2009
|
+
}
|
|
2010
|
+
config.accessToken = sessionData.session.access_token;
|
|
2011
|
+
config.refreshToken = sessionData.session.refresh_token;
|
|
2012
|
+
await saveConfig(config);
|
|
2013
|
+
supabase.auth.onAuthStateChange((event, session) => {
|
|
2014
|
+
if (!session)
|
|
2015
|
+
return;
|
|
2016
|
+
if (event === 'TOKEN_REFRESHED' || event === 'SIGNED_IN') {
|
|
2017
|
+
config.accessToken = session.access_token;
|
|
2018
|
+
config.refreshToken = session.refresh_token;
|
|
2019
|
+
void saveConfig(config)
|
|
2020
|
+
.then(() => logInfo('Gateway session refreshed'))
|
|
2021
|
+
.catch((error) => {
|
|
2022
|
+
logError('Failed to persist refreshed gateway session', {
|
|
2023
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
await ensureClaudeGatewayHome();
|
|
2029
|
+
const capabilities = await buildCapabilities();
|
|
2030
|
+
const providerStatus = (capabilities.providers ?? {});
|
|
2031
|
+
const anyProviderReady = Object.values(providerStatus).some((entry) => entry?.available === true);
|
|
2032
|
+
const initialStatus = anyProviderReady ? 'ready' : 'error';
|
|
2033
|
+
logInfo('Gateway capabilities loaded', {
|
|
2034
|
+
providers: Object.fromEntries(Object.entries(providerStatus).map(([id, entry]) => [
|
|
2035
|
+
id,
|
|
2036
|
+
{
|
|
2037
|
+
available: entry.available,
|
|
2038
|
+
version: entry.version ?? null,
|
|
2039
|
+
},
|
|
2040
|
+
])),
|
|
2041
|
+
concurrency: GATEWAY_CONCURRENCY,
|
|
2042
|
+
});
|
|
2043
|
+
await sendHeartbeat(supabase, initialStatus, capabilities, deviceName);
|
|
2044
|
+
let heartbeatStatus = initialStatus;
|
|
2045
|
+
await writePidFile(process.pid);
|
|
2046
|
+
const heartbeatTimer = setInterval(() => {
|
|
2047
|
+
void sendHeartbeat(supabase, heartbeatStatus, capabilities, deviceName);
|
|
2048
|
+
}, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
2049
|
+
const pendingJobs = [];
|
|
2050
|
+
const queuedJobIds = new Set();
|
|
2051
|
+
const activeJobIds = new Set();
|
|
2052
|
+
const processCancelJob = async (job) => {
|
|
2053
|
+
const claimed = await claimJob(supabase, config, job);
|
|
2054
|
+
if (!claimed)
|
|
2055
|
+
return;
|
|
2056
|
+
const result = await handleSubagentCancelJob(supabase, claimed);
|
|
2057
|
+
if (result.ok) {
|
|
2058
|
+
await completeJob(supabase, claimed.id, 'completed', result.result);
|
|
2059
|
+
}
|
|
2060
|
+
else {
|
|
2061
|
+
await completeJob(supabase, claimed.id, 'failed', result.result, result.error);
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
const processJobWithHandling = async (nextJob) => {
|
|
2065
|
+
try {
|
|
2066
|
+
await processJob(supabase, config, nextJob);
|
|
2067
|
+
}
|
|
2068
|
+
catch (error) {
|
|
2069
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2070
|
+
logError('Gateway job processing failed', {
|
|
2071
|
+
jobId: nextJob.id,
|
|
2072
|
+
error: errorMessage,
|
|
2073
|
+
});
|
|
2074
|
+
await completeJob(supabase, nextJob.id, 'failed', { message: 'Gateway job processing failed', job_type: nextJob.job_type }, errorMessage);
|
|
2075
|
+
if (nextJob.job_type === 'subagent_run') {
|
|
2076
|
+
await failSubagentRun(supabase, nextJob, errorMessage);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
finally {
|
|
2080
|
+
activeJobIds.delete(nextJob.id);
|
|
2081
|
+
void processQueue();
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
const processQueue = async () => {
|
|
2085
|
+
while (activeJobIds.size < GATEWAY_CONCURRENCY && pendingJobs.length > 0) {
|
|
2086
|
+
const nextJob = pendingJobs.shift();
|
|
2087
|
+
if (!nextJob)
|
|
2088
|
+
break;
|
|
2089
|
+
queuedJobIds.delete(nextJob.id);
|
|
2090
|
+
if (activeJobIds.has(nextJob.id))
|
|
2091
|
+
continue;
|
|
2092
|
+
activeJobIds.add(nextJob.id);
|
|
2093
|
+
void processJobWithHandling(nextJob);
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
const enqueueJob = (job) => {
|
|
2097
|
+
if (!isJobTargeted(job, config.gatewayId))
|
|
2098
|
+
return;
|
|
2099
|
+
if (job.job_type === 'subagent_cancel') {
|
|
2100
|
+
void processCancelJob(job);
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (queuedJobIds.has(job.id) || activeJobIds.has(job.id)) {
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
pendingJobs.push(job);
|
|
2107
|
+
queuedJobIds.add(job.id);
|
|
2108
|
+
void processQueue();
|
|
2109
|
+
};
|
|
2110
|
+
const { data: backlog, error: backlogError } = await supabase
|
|
2111
|
+
.from('gateway_jobs')
|
|
2112
|
+
.select('*')
|
|
2113
|
+
.eq('team_id', config.teamId)
|
|
2114
|
+
.eq('status', 'queued')
|
|
2115
|
+
.or(`gateway_id.is.null,gateway_id.eq.${config.gatewayId}`)
|
|
2116
|
+
.order('created_at', { ascending: true });
|
|
2117
|
+
if (backlogError) {
|
|
2118
|
+
logError('Failed to fetch queued gateway jobs', { error: backlogError.message });
|
|
2119
|
+
}
|
|
2120
|
+
else if (backlog && backlog.length > 0) {
|
|
2121
|
+
backlog.forEach((job) => enqueueJob(job));
|
|
2122
|
+
}
|
|
2123
|
+
const channel = supabase
|
|
2124
|
+
.channel(`gateway_jobs_${config.gatewayId}`)
|
|
2125
|
+
.on('postgres_changes', {
|
|
2126
|
+
event: 'INSERT',
|
|
2127
|
+
schema: 'public',
|
|
2128
|
+
table: 'gateway_jobs',
|
|
2129
|
+
filter: `team_id=eq.${config.teamId}`,
|
|
2130
|
+
}, (payload) => {
|
|
2131
|
+
const job = payload.new;
|
|
2132
|
+
if (job.status !== 'queued')
|
|
2133
|
+
return;
|
|
2134
|
+
enqueueJob(job);
|
|
2135
|
+
})
|
|
2136
|
+
.subscribe((status) => {
|
|
2137
|
+
if (status === 'SUBSCRIBED') {
|
|
2138
|
+
logInfo('Gateway subscribed to job queue');
|
|
2139
|
+
}
|
|
2140
|
+
if (status === 'CHANNEL_ERROR') {
|
|
2141
|
+
logError('Gateway realtime channel error');
|
|
2142
|
+
heartbeatStatus = 'error';
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
const shutdown = async (signal) => {
|
|
2146
|
+
logInfo('Gateway shutting down', { signal });
|
|
2147
|
+
clearInterval(heartbeatTimer);
|
|
2148
|
+
heartbeatStatus = 'offline';
|
|
2149
|
+
await sendHeartbeat(supabase, 'offline', capabilities, deviceName);
|
|
2150
|
+
await channel.unsubscribe();
|
|
2151
|
+
await removePidFile();
|
|
2152
|
+
process.exit(0);
|
|
2153
|
+
};
|
|
2154
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
2155
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
2156
|
+
logInfo('Gateway running', {
|
|
2157
|
+
gatewayId: config.gatewayId,
|
|
2158
|
+
teamId: config.teamId,
|
|
2159
|
+
deviceName,
|
|
2160
|
+
heartbeatIntervalMs: DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
2161
|
+
concurrency: GATEWAY_CONCURRENCY,
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
async function stopGateway() {
|
|
2165
|
+
const pid = readPidFile();
|
|
2166
|
+
if (!pid) {
|
|
2167
|
+
logInfo('No gateway pid file found', { pidPath: PID_PATH });
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (!isProcessAlive(pid)) {
|
|
2171
|
+
logInfo('Gateway not running, removing stale pid file', { pid });
|
|
2172
|
+
await removePidFile();
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
try {
|
|
2176
|
+
process.kill(pid, 'SIGTERM');
|
|
2177
|
+
logInfo('Sent SIGTERM to gateway process', { pid });
|
|
2178
|
+
}
|
|
2179
|
+
catch (error) {
|
|
2180
|
+
logError('Failed to stop gateway process', {
|
|
2181
|
+
pid,
|
|
2182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2183
|
+
});
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
const timeoutMs = 5000;
|
|
2187
|
+
const start = Date.now();
|
|
2188
|
+
while (Date.now() - start < timeoutMs) {
|
|
2189
|
+
if (!isProcessAlive(pid)) {
|
|
2190
|
+
await removePidFile();
|
|
2191
|
+
logInfo('Gateway stopped', { pid });
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2195
|
+
}
|
|
2196
|
+
logInfo('Gateway stop timed out; process may still be running', { pid });
|
|
2197
|
+
}
|
|
2198
|
+
async function showLogs(options) {
|
|
2199
|
+
const linesRaw = getStringOption(options, 'lines');
|
|
2200
|
+
const lines = linesRaw ? Number.parseInt(linesRaw, 10) : 200;
|
|
2201
|
+
const follow = options['no-follow'] !== true;
|
|
2202
|
+
try {
|
|
2203
|
+
const content = await fs.readFile(LOG_PATH, 'utf-8');
|
|
2204
|
+
const allLines = content.split(/\r?\n/);
|
|
2205
|
+
const tail = Number.isFinite(lines) && lines > 0 ? allLines.slice(-lines) : allLines;
|
|
2206
|
+
process.stdout.write(`${tail.join('\n')}\n`);
|
|
2207
|
+
}
|
|
2208
|
+
catch (error) {
|
|
2209
|
+
logError('Unable to read gateway log file', {
|
|
2210
|
+
logPath: LOG_PATH,
|
|
2211
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2212
|
+
});
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
if (!follow)
|
|
2216
|
+
return;
|
|
2217
|
+
let position = 0;
|
|
2218
|
+
try {
|
|
2219
|
+
const stat = await fs.stat(LOG_PATH);
|
|
2220
|
+
position = stat.size;
|
|
2221
|
+
}
|
|
2222
|
+
catch {
|
|
2223
|
+
position = 0;
|
|
2224
|
+
}
|
|
2225
|
+
logInfo('Tailing gateway logs', { logPath: LOG_PATH });
|
|
2226
|
+
fsSync.watch(LOG_PATH, { persistent: true }, async (event) => {
|
|
2227
|
+
if (event !== 'change')
|
|
2228
|
+
return;
|
|
2229
|
+
try {
|
|
2230
|
+
const stat = await fs.stat(LOG_PATH);
|
|
2231
|
+
if (stat.size < position) {
|
|
2232
|
+
position = 0;
|
|
2233
|
+
}
|
|
2234
|
+
const handle = await fs.open(LOG_PATH, 'r');
|
|
2235
|
+
const length = stat.size - position;
|
|
2236
|
+
if (length > 0) {
|
|
2237
|
+
const buffer = Buffer.alloc(length);
|
|
2238
|
+
await handle.read(buffer, 0, length, position);
|
|
2239
|
+
position = stat.size;
|
|
2240
|
+
process.stdout.write(buffer.toString('utf-8'));
|
|
2241
|
+
}
|
|
2242
|
+
await handle.close();
|
|
2243
|
+
}
|
|
2244
|
+
catch (error) {
|
|
2245
|
+
logError('Failed to tail gateway logs', {
|
|
2246
|
+
logPath: LOG_PATH,
|
|
2247
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
await new Promise(() => undefined);
|
|
2252
|
+
}
|
|
2253
|
+
async function run() {
|
|
2254
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
2255
|
+
if (parsed.options.h || parsed.options.help || parsed.command === null) {
|
|
2256
|
+
printHelp();
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (parsed.command === 'pair') {
|
|
2260
|
+
loadEnvironment(parsed.options);
|
|
2261
|
+
const code = getStringOption(parsed.options, 'code') || parsed.positional[0];
|
|
2262
|
+
if (!code) {
|
|
2263
|
+
throw new Error('Pairing code is required');
|
|
2264
|
+
}
|
|
2265
|
+
await pairGateway(code, parsed.options);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
if (parsed.command === 'start') {
|
|
2269
|
+
loadEnvironment(parsed.options);
|
|
2270
|
+
await startGateway(parsed.options);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (parsed.command === 'stop') {
|
|
2274
|
+
await stopGateway();
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
if (parsed.command === 'logs') {
|
|
2278
|
+
await showLogs(parsed.options);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
printHelp();
|
|
2282
|
+
process.exitCode = 1;
|
|
2283
|
+
}
|
|
2284
|
+
run().catch((error) => {
|
|
2285
|
+
logError('Gateway failed', { error: error instanceof Error ? error.message : String(error) });
|
|
2286
|
+
process.exitCode = 1;
|
|
2287
|
+
});
|
|
2288
|
+
//# sourceMappingURL=index.js.map
|