@xuda.io/ai_module 1.1.5655 → 1.1.5656
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/index.mjs +2636 -250
- package/index_ms.mjs +84 -0
- package/index_msa.mjs +84 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -98,6 +98,71 @@ const run_process = function (command, args, input, options = {}) {
|
|
|
98
98
|
});
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
// UI-220. Same spawn, opposite lifetime: start the process, hand back its pid and walk away, with
|
|
102
|
+
// stdout and stderr going straight to a file that never passes through this process. A code run
|
|
103
|
+
// has to survive a pm2 restart, and on dev pm2 watches cpi/ai_module so every deploy is one.
|
|
104
|
+
//
|
|
105
|
+
// `detached: true` alone is NOT enough, and this was measured rather than assumed: pm2 defaults to
|
|
106
|
+
// treekill, which walks CHILDREN BY PPID and kills them regardless of process group, so a detached
|
|
107
|
+
// child still died with its parent. `setsid` is what breaks the parentage: it forks, exits, and the
|
|
108
|
+
// run is re-parented to init, where nothing walking a pm2 process tree can find it.
|
|
109
|
+
//
|
|
110
|
+
// Two details follow from going through setsid:
|
|
111
|
+
// • the prompt goes to a FILE and is redirected in, because there is no pipe left to write to
|
|
112
|
+
// once the intermediate has exited (and the file is useful on its own when reading back a run),
|
|
113
|
+
// • the run records its own pid, since setsid's pid is not the one we need to poll or kill. The
|
|
114
|
+
// script writes $$ and then EXECs, so that pid is the real process, and it is a session leader,
|
|
115
|
+
// which is what makes `kill(-pid)` reach the CLI shim plus the binary it spawns.
|
|
116
|
+
// A box without setsid (never the fleet, which already relies on it elsewhere) falls back to a
|
|
117
|
+
// plain detached child: no worse than the behaviour this replaces.
|
|
118
|
+
const shell_quote = function (value) {
|
|
119
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const spawn_detached_to_log = async function (command, args, input, options = {}) {
|
|
123
|
+
const { log_path, ...spawn_options } = options;
|
|
124
|
+
const dir = path.dirname(log_path);
|
|
125
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
126
|
+
const prompt_path = path.join(dir, 'prompt.txt');
|
|
127
|
+
const pid_path = path.join(dir, 'pid');
|
|
128
|
+
await fs.promises.writeFile(prompt_path, input || '');
|
|
129
|
+
|
|
130
|
+
const has_setsid = (await run_process('bash', ['-lc', 'command -v setsid >/dev/null 2>&1 && echo yes || echo no'], null, { timeout: 10000 })).stdout.trim() === 'yes';
|
|
131
|
+
|
|
132
|
+
if (has_setsid) {
|
|
133
|
+
const cmdline = [command, ...args].map(shell_quote).join(' ');
|
|
134
|
+
const script = `echo $$ > ${shell_quote(pid_path)}; exec ${cmdline} < ${shell_quote(prompt_path)} >> ${shell_quote(log_path)} 2>&1`;
|
|
135
|
+
const child = spawn('setsid', ['bash', '-c', script], { detached: true, stdio: 'ignore', ...spawn_options });
|
|
136
|
+
child.unref();
|
|
137
|
+
|
|
138
|
+
// The pid file appears as soon as the grandchild starts, which is the only moment we can
|
|
139
|
+
// learn the pid we will be polling and killing for the rest of the run.
|
|
140
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
const pid = Number((await fs.promises.readFile(pid_path, 'utf8')).trim());
|
|
143
|
+
if (pid > 0) return { pid, detach: 'setsid' };
|
|
144
|
+
} catch (err) {}
|
|
145
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
146
|
+
}
|
|
147
|
+
throw new Error('code run did not report a pid');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fd = await fs.promises.open(log_path, 'a');
|
|
151
|
+
try {
|
|
152
|
+
const child = spawn(command, args, { detached: true, stdio: ['pipe', fd.fd, fd.fd], ...spawn_options });
|
|
153
|
+
return await new Promise((resolve, reject) => {
|
|
154
|
+
child.on('error', reject);
|
|
155
|
+
child.stdin.on('error', () => {});
|
|
156
|
+
if (input) child.stdin.write(input);
|
|
157
|
+
child.stdin.end();
|
|
158
|
+
child.unref();
|
|
159
|
+
setImmediate(() => resolve({ pid: child.pid, detach: 'spawn' }));
|
|
160
|
+
});
|
|
161
|
+
} finally {
|
|
162
|
+
await fd.close();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
101
166
|
const normalize_boolean = function (value) {
|
|
102
167
|
if (typeof value === 'string') {
|
|
103
168
|
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
|
|
@@ -303,7 +368,20 @@ const get_openai_codex_usage_model = function (codex_model, requested_codex_mode
|
|
|
303
368
|
};
|
|
304
369
|
|
|
305
370
|
const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
306
|
-
|
|
371
|
+
// UI-220. `exec resume` continues the Codex thread this chat already has instead of starting a
|
|
372
|
+
// cold one, so turn 2 knows what turn 1 did to the box. The session id comes off the
|
|
373
|
+
// `thread.started` event and is stored on the conversation; the caller only passes it when the
|
|
374
|
+
// session was recorded on THIS host, because Codex keeps its rollouts in local files under
|
|
375
|
+
// ~/.codex/sessions.
|
|
376
|
+
//
|
|
377
|
+
// Two differences from a cold `exec`, both enforced by the CLI (verified on 0.128.0):
|
|
378
|
+
// • the signature is `exec resume [OPTIONS] <SESSION_ID> [PROMPT]`, so the id and the `-` are
|
|
379
|
+
// POSITIONAL and the caller appends them after every option (see launch_code_run),
|
|
380
|
+
// • `--sandbox <mode>` is not accepted at all ("unexpected argument '--sandbox' found"): a
|
|
381
|
+
// resumed session keeps the sandbox it was created with. Only the bypass flag and
|
|
382
|
+
// --skip-git-repo-check carry over.
|
|
383
|
+
const resuming = !!opts.resume_session_id;
|
|
384
|
+
const args = resuming ? ['exec', 'resume', '--json'] : ['exec', '--json'];
|
|
307
385
|
// A per-call sandbox (e.g. the AI site generator passes 'workspace-write')
|
|
308
386
|
// forces a confined run regardless of the global bypass default, so untrusted
|
|
309
387
|
// end-user prompts can't run Codex with full host access. The studio/vibe flow
|
|
@@ -331,11 +409,15 @@ const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
|
331
409
|
|
|
332
410
|
if (bypass_approvals_and_sandbox) {
|
|
333
411
|
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
334
|
-
} else if (sandbox) {
|
|
412
|
+
} else if (sandbox && !resuming) {
|
|
335
413
|
// --skip-git-repo-check: draft folders aren't git repos; without it codex
|
|
336
414
|
// refuses to run sandboxed ("not inside a trusted directory"). The bypass
|
|
337
415
|
// path skipped this implicitly.
|
|
338
416
|
args.push('--sandbox', sandbox, '--skip-git-repo-check');
|
|
417
|
+
} else if (sandbox) {
|
|
418
|
+
// Resuming: the mode itself is fixed by the original session, but the repo check still has
|
|
419
|
+
// to be waived or a resumed run in a non-git directory refuses to start.
|
|
420
|
+
args.push('--skip-git-repo-check');
|
|
339
421
|
}
|
|
340
422
|
|
|
341
423
|
return args;
|
|
@@ -2055,120 +2137,1756 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
|
|
|
2055
2137
|
The site's source files are in your current working directory: ${local_cwd}
|
|
2056
2138
|
Edit them directly with your normal file tools (read, search, apply patches) — everything is local; do NOT use SSH or scp. Keep changes scoped to the user's request and preserve the rest of the site.
|
|
2057
2139
|
|
|
2058
|
-
This is the editable source, not the live site. Do NOT deploy or publish anything — the user publishes separately by clicking Publish. When you finish, briefly summarize what you changed.
|
|
2140
|
+
This is the editable source, not the live site. Do NOT deploy or publish anything — the user publishes separately by clicking Publish. When you finish, briefly summarize what you changed.
|
|
2141
|
+
|
|
2142
|
+
User request:
|
|
2143
|
+
${prompt}`
|
|
2144
|
+
: `You are OpenAI Codex running on the local Xuda server.
|
|
2145
|
+
|
|
2146
|
+
The target remote machine is ${get_codex_remote_host(ip)}.
|
|
2147
|
+
Execute shell commands on the remote machine through SSH, for example:
|
|
2148
|
+
ssh -o BatchMode=yes -o StrictHostKeyChecking=no ${get_codex_remote_host(ip)} "<command>"
|
|
2149
|
+
|
|
2150
|
+
Do not assume the current local filesystem is the remote machine. Use SSH for inspection, edits, installs, and command execution unless the user explicitly asks for local work.
|
|
2151
|
+
For generated websites or other large file payloads, create the files first on the local Xuda server in a temporary working directory, validate them locally, then copy them to the remote machine with scp and use SSH only for remote placement, permissions, service reloads, and verification. Avoid embedding long HTML, CSS, JavaScript, JSON, or scripts inside nested ssh heredocs because shell quoting can corrupt the generated files.
|
|
2152
|
+
|
|
2153
|
+
User request:
|
|
2154
|
+
${prompt}`;
|
|
2155
|
+
|
|
2156
|
+
if (local_attachments.length) {
|
|
2157
|
+
codex_prompt += `\n\nAttachments available to Codex on the local Xuda server:\n${local_attachments.map((attachment_path) => `- ${attachment_path}`).join('\n')}`;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
if (remote_attachments.length) {
|
|
2161
|
+
codex_prompt += `\n\nAttachments available on the remote machine:\n${remote_attachments.map((attachment_path) => `- ${attachment_path}`).join('\n')}`;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const openai_codex_command = get_openai_codex_cli_command();
|
|
2165
|
+
let codex_args = get_openai_codex_exec_args(codex_model, { sandbox, bypass_sandbox });
|
|
2166
|
+
if (agent_mcp && agent_mcp.url) {
|
|
2167
|
+
// Register the Xuda MCP server for this run. Global `-c` overrides must
|
|
2168
|
+
// precede the `exec` subcommand (args[0]), so prepend them. The bearer
|
|
2169
|
+
// travels by env var name (resolved from the run env below), not value.
|
|
2170
|
+
codex_args = ['-c', `mcp_servers.xuda.url="${agent_mcp.url}"`, '-c', `mcp_servers.xuda.bearer_token_env_var="${agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY'}"`, ...codex_args];
|
|
2171
|
+
}
|
|
2172
|
+
for (const image_path of image_paths) {
|
|
2173
|
+
codex_args.push('--image', image_path);
|
|
2174
|
+
}
|
|
2175
|
+
codex_args.push('-');
|
|
2176
|
+
|
|
2177
|
+
const ret = await run_process(openai_codex_command, codex_args, codex_prompt, {
|
|
2178
|
+
env: {
|
|
2179
|
+
...process.env,
|
|
2180
|
+
OPENAI_API_KEY: _conf.OPENAI_API_KEY,
|
|
2181
|
+
...(agent_mcp && agent_mcp.bearer_value ? { [agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY']: agent_mcp.bearer_value } : {}),
|
|
2182
|
+
},
|
|
2183
|
+
...(local_cwd ? { cwd: local_cwd } : {}),
|
|
2184
|
+
...(codex_timeout ? { timeout: codex_timeout, killSignal: 'SIGKILL' } : {}),
|
|
2185
|
+
onStdout: create_codex_jsonl_stream_parser(handleCodexEvent),
|
|
2186
|
+
});
|
|
2187
|
+
const events = parse_codex_jsonl(ret.stdout);
|
|
2188
|
+
if (!codex_usage.input_tokens && !codex_usage.output_tokens && Array.isArray(events)) {
|
|
2189
|
+
const completed_event_with_usage = events.findLast((event) => event?.type === 'turn.completed' && event.usage);
|
|
2190
|
+
codex_usage = normalize_codex_usage(completed_event_with_usage?.usage);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (uid && account_profile_info && (codex_usage.input_tokens || codex_usage.output_tokens)) {
|
|
2194
|
+
account_msa.record_ai_usage(
|
|
2195
|
+
uid,
|
|
2196
|
+
codex_usage.input_tokens,
|
|
2197
|
+
codex_usage.output_tokens,
|
|
2198
|
+
'execute codex request',
|
|
2199
|
+
prompt,
|
|
2200
|
+
get_openai_codex_usage_model(codex_model, requested_codex_model),
|
|
2201
|
+
{
|
|
2202
|
+
conversation_id,
|
|
2203
|
+
job_id,
|
|
2204
|
+
remote_host: local_cwd ? null : get_codex_remote_host(ip),
|
|
2205
|
+
exit_code: ret.exit_code,
|
|
2206
|
+
attachments_count: attachments.length,
|
|
2207
|
+
},
|
|
2208
|
+
account_profile_info,
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
if (ret.exit_code !== 0) {
|
|
2213
|
+
emitToDashboard('stream_phase', 'Codex request failed', { update: true });
|
|
2214
|
+
streamText(`Codex request failed: ${ret.stderr || ret.stdout || `exit code ${ret.exit_code}`}`);
|
|
2215
|
+
emitToDashboard('stream_end');
|
|
2216
|
+
|
|
2217
|
+
return {
|
|
2218
|
+
code: -3,
|
|
2219
|
+
data: {
|
|
2220
|
+
provider: 'openai_codex_cli',
|
|
2221
|
+
remote_host: local_cwd ? null : get_codex_remote_host(ip),
|
|
2222
|
+
command: `${openai_codex_command} ${codex_args.filter((arg) => arg !== '-').join(' ')}`,
|
|
2223
|
+
exit_code: ret.exit_code,
|
|
2224
|
+
stdout: ret.stdout,
|
|
2225
|
+
stderr: ret.stderr,
|
|
2226
|
+
events,
|
|
2227
|
+
reasoning,
|
|
2228
|
+
usage: codex_usage,
|
|
2229
|
+
},
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
emitToDashboard('stream_end');
|
|
2234
|
+
|
|
2235
|
+
return {
|
|
2236
|
+
code: 0,
|
|
2237
|
+
data: {
|
|
2238
|
+
provider: 'openai_codex_cli',
|
|
2239
|
+
remote_host: local_cwd ? null : get_codex_remote_host(ip),
|
|
2240
|
+
command: `${openai_codex_command} ${codex_args.filter((arg) => arg !== '-').join(' ')}`,
|
|
2241
|
+
stdout: ret.stdout,
|
|
2242
|
+
stderr: ret.stderr,
|
|
2243
|
+
events,
|
|
2244
|
+
reasoning,
|
|
2245
|
+
usage: codex_usage,
|
|
2246
|
+
},
|
|
2247
|
+
};
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
console.error(err);
|
|
2250
|
+
emitToDashboard('stream_phase', 'Codex request failed', { update: true });
|
|
2251
|
+
streamText(`Codex request failed: ${err.message}`);
|
|
2252
|
+
emitToDashboard('stream_end');
|
|
2253
|
+
return { code: -1, data: err.message };
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
|
|
2257
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
2258
|
+
// Durable code runs (UI-220)
|
|
2259
|
+
//
|
|
2260
|
+
// A code chat turn (vibe on a VPS, an AI edit of a static site) is a Codex CLI run that takes
|
|
2261
|
+
// minutes. It used to be awaited inline inside the chat job, as a child of the `ai` process,
|
|
2262
|
+
// with `stream: false`. That made three failures routine:
|
|
2263
|
+
// • a pm2 restart killed the run and left no record of what it had already done to the box
|
|
2264
|
+
// (on dev that is EVERY deploy, since pm2 watches cpi/ai_module),
|
|
2265
|
+
// • a run outliving its 10 minute job finished into a job that no longer existed,
|
|
2266
|
+
// • the browser was sent nothing at all while it worked, which is what made code chats feel
|
|
2267
|
+
// broken even on the runs that succeeded.
|
|
2268
|
+
//
|
|
2269
|
+
// A run is now its own object with four parts:
|
|
2270
|
+
// • a DETACHED process, so it outlives whatever started it,
|
|
2271
|
+
// • a JSONL log FILE it appends to, which is the transcript of record,
|
|
2272
|
+
// • a `code_run` doc in the account's project db (state, commands, touched files, usage),
|
|
2273
|
+
// • a `meta.json` beside the log, so a restarted box can find its own runs again without
|
|
2274
|
+
// depending on memcached or on scanning every account database.
|
|
2275
|
+
//
|
|
2276
|
+
// The socket is a live VIEW of the log rather than the only copy of it, so anything the browser
|
|
2277
|
+
// misses stays readable from the doc afterwards, which is exactly what the chat's own recovery
|
|
2278
|
+
// probe (UI-219) asks the server for.
|
|
2279
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
2280
|
+
|
|
2281
|
+
const CODE_RUN_MAX_CONCURRENT = _conf.code_runs?.max_concurrent || 3;
|
|
2282
|
+
const CODE_RUN_TIMEOUT_MS = _conf.code_runs?.timeout_ms || 20 * 60 * 1000;
|
|
2283
|
+
const CODE_RUN_RETENTION_MS = _conf.code_runs?.retention_ms || 7 * 24 * 60 * 60 * 1000;
|
|
2284
|
+
// How often the log is read, and how often what was read is flushed to the doc. The tail is a
|
|
2285
|
+
// poll rather than an fs.watch because the writer is another process (and on a restart, one this
|
|
2286
|
+
// process never spawned), and a poll is also how process liveness is checked.
|
|
2287
|
+
const CODE_RUN_TAIL_MS = 700;
|
|
2288
|
+
const CODE_RUN_FLUSH_MS = 2500;
|
|
2289
|
+
// Read at most this much log per tick, so a run that dumped a huge command output cannot pull
|
|
2290
|
+
// megabytes into memory in one go.
|
|
2291
|
+
const CODE_RUN_READ_CHUNK = 512 * 1024;
|
|
2292
|
+
// Directories a code run is allowed to watch for file changes, per app type. Deliberately
|
|
2293
|
+
// bounded and declared: snapshotting a whole box is not affordable, and a file panel that
|
|
2294
|
+
// claims to be complete has to say what it looked at.
|
|
2295
|
+
const CODE_RUN_DEFAULT_WATCH_PATHS = ['/var/www', '/etc/nginx', '/etc/apache2', '/opt/app'];
|
|
2296
|
+
const CODE_RUN_WATCH_IGNORE = ['node_modules', '.git', '.xuda_code.git', 'vendor', '*.log', 'tmp', '.cache'];
|
|
2297
|
+
// Ceiling for the no-git fallback, which has to make a real copy. Past this the root is reported
|
|
2298
|
+
// as skipped rather than quietly copied on the customer's disk.
|
|
2299
|
+
const CODE_RUN_COPY_MAX_BYTES = _conf.code_runs?.copy_max_bytes || 200 * 1024 * 1024;
|
|
2300
|
+
|
|
2301
|
+
const code_run_host = function () {
|
|
2302
|
+
return process.env.XUDA_HOSTNAME || _conf.domain || 'unknown';
|
|
2303
|
+
};
|
|
2304
|
+
|
|
2305
|
+
const code_run_root_dir = function () {
|
|
2306
|
+
return path.join(process.env.XUDA_HOME, 'code_runs');
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
const code_run_dir = function (run_id) {
|
|
2310
|
+
return path.join(code_run_root_dir(), run_id);
|
|
2311
|
+
};
|
|
2312
|
+
|
|
2313
|
+
const code_run_log_path = function (run_id) {
|
|
2314
|
+
return path.join(code_run_dir(run_id), 'codex.jsonl');
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
const code_run_meta_path = function (run_id) {
|
|
2318
|
+
return path.join(code_run_dir(run_id), 'meta.json');
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
const code_run_backup_dir = function (run_id) {
|
|
2322
|
+
return path.join(code_run_dir(run_id), 'before');
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
// Every shell command a run needs (snapshots, diffs, reverts) is either local or on the
|
|
2326
|
+
// customer's box, and the difference is only this wrapper. Same ssh options Codex itself is told
|
|
2327
|
+
// to use, so a box reachable by the agent is reachable here.
|
|
2328
|
+
const CODE_RUN_SSH_OPTS = '-o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10';
|
|
2329
|
+
const code_run_exec = async function (run, command, timeout_ms = 60000) {
|
|
2330
|
+
const full = run.remote_host ? `ssh ${CODE_RUN_SSH_OPTS} ${run.remote_host} ${JSON.stringify(command)}` : command;
|
|
2331
|
+
return await run_process('bash', ['-lc', full], null, { timeout: timeout_ms, killSignal: 'SIGKILL' });
|
|
2332
|
+
};
|
|
2333
|
+
|
|
2334
|
+
// ── Run doc I/O ────────────────────────────────────────────────────────────────────────────
|
|
2335
|
+
// The in-memory run object carries its own _rev so the tail can write often without re-reading.
|
|
2336
|
+
// save_app_couch_doc already retries a 409 by re-reading and merging, so a concurrent writer
|
|
2337
|
+
// (an abort, the controller sweep) costs a retry rather than a lost update.
|
|
2338
|
+
// Underscore keys are this process's working state (the accumulated stream text, the set of
|
|
2339
|
+
// agent messages already sent, the pending spawn arguments, the conversation doc). None of it
|
|
2340
|
+
// belongs in the doc: a Set does not survive JSON, and the prompt and conversation would bloat
|
|
2341
|
+
// every write. _id and _rev are the two that do.
|
|
2342
|
+
const CODE_RUN_TRANSIENT_KEY = /^_(?!id$|rev$)/;
|
|
2343
|
+
const persist_code_run = async function (run) {
|
|
2344
|
+
const payload = {};
|
|
2345
|
+
for (const [key, value] of Object.entries(run)) {
|
|
2346
|
+
if (!CODE_RUN_TRANSIENT_KEY.test(key)) payload[key] = value;
|
|
2347
|
+
}
|
|
2348
|
+
payload._id = run._id;
|
|
2349
|
+
if (run._rev) payload._rev = run._rev;
|
|
2350
|
+
try {
|
|
2351
|
+
const ret = await db_module.save_app_couch_doc_native(run.app_id, payload);
|
|
2352
|
+
if (ret?.rev) run._rev = ret.rev;
|
|
2353
|
+
return true;
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
console.error(`[code_run] persist ${run._id} failed: ${err?.message || err}`);
|
|
2356
|
+
return false;
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
const load_code_run = async function (app_id, run_id) {
|
|
2361
|
+
try {
|
|
2362
|
+
const doc = await db_module.get_app_couch_doc_native(app_id, run_id);
|
|
2363
|
+
return doc && doc.docType === 'code_run' ? doc : null;
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
// ── File-state capture ─────────────────────────────────────────────────────────────────────
|
|
2370
|
+
// Before the run writes anything, record what the watched directories look like. Two mechanisms,
|
|
2371
|
+
// both chosen so they work on a box nobody prepared for us:
|
|
2372
|
+
// • git, when it is installed: a SHADOW repo (its own git dir, the real tree as work tree) so
|
|
2373
|
+
// the customer's own git history, if any, is untouched. Gives paths, diffs and revert, and
|
|
2374
|
+
// honours the ignore list natively, which is why it is preferred.
|
|
2375
|
+
// • otherwise `cp -a --reflink=auto`: a real copy, cheap where the filesystem can share blocks
|
|
2376
|
+
// (copy-on-write) and a plain copy everywhere else. It is NOT `cp -al`: a hardlinked snapshot
|
|
2377
|
+
// looks free but is wrong here, because a shell redirect (`> file`, `tee`, `sed -i` without a
|
|
2378
|
+
// temp file) writes the SAME inode, so the "backup" changes with the original and the edit
|
|
2379
|
+
// becomes invisible. Verified: a hardlink snapshot reported an edited file as unchanged.
|
|
2380
|
+
// Both are bounded to the declared watch paths, and the copy path is additionally bounded by
|
|
2381
|
+
// size: past the cap the root is recorded as skipped WITH the reason, because a file panel that
|
|
2382
|
+
// cannot see a directory has to say so rather than imply the run touched nothing there.
|
|
2383
|
+
// A run that cannot snapshot at all still runs; it just reports no file list.
|
|
2384
|
+
const code_run_watch_paths = function (app_obj, local_cwd) {
|
|
2385
|
+
if (local_cwd) return [local_cwd];
|
|
2386
|
+
const per_type = _conf.code_runs?.watch_paths?.[app_obj?.app_type];
|
|
2387
|
+
const configured = per_type || _conf.code_runs?.watch_paths?.default || CODE_RUN_DEFAULT_WATCH_PATHS;
|
|
2388
|
+
return Array.isArray(configured) ? configured : [];
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
const code_run_shadow_git = function (root) {
|
|
2392
|
+
return `git --git-dir=${JSON.stringify(`${root}/.xuda_code.git`)} --work-tree=${JSON.stringify(root)}`;
|
|
2393
|
+
};
|
|
2394
|
+
|
|
2395
|
+
const code_run_capture_before = async function (run) {
|
|
2396
|
+
const roots = [];
|
|
2397
|
+
// UI-226: a repo-backed run needs no snapshot at all. Its working copy IS a repository, so the
|
|
2398
|
+
// before state is just HEAD, and the files it already had dirty are recorded so a change somebody
|
|
2399
|
+
// made by hand is never attributed to the run. The shadow-repo and copy paths below stay exactly as
|
|
2400
|
+
// they were for everything else, which is what keeps the VPS behaviour unchanged.
|
|
2401
|
+
if (run.repo_id && run.base_sha) {
|
|
2402
|
+
run.snapshot = { roots: [{ root: run.local_cwd, kind: 'repo', ref: run.base_sha, pre_dirty: run.pre_dirty || [] }], taken_ts: Date.now(), watched: [run.local_cwd] };
|
|
2403
|
+
return run.snapshot;
|
|
2404
|
+
}
|
|
2405
|
+
try {
|
|
2406
|
+
const has_git = (await code_run_exec(run, 'command -v git >/dev/null 2>&1 && echo yes || echo no', 20000)).stdout.trim() === 'yes';
|
|
2407
|
+
for (const root of run.watch_paths || []) {
|
|
2408
|
+
const exists = (await code_run_exec(run, `test -d ${JSON.stringify(root)} && echo yes || echo no`, 20000)).stdout.trim() === 'yes';
|
|
2409
|
+
if (!exists) continue;
|
|
2410
|
+
|
|
2411
|
+
if (has_git) {
|
|
2412
|
+
const git = code_run_shadow_git(root);
|
|
2413
|
+
const exclude = CODE_RUN_WATCH_IGNORE.join('\\n');
|
|
2414
|
+
const init = `${git} rev-parse --git-dir >/dev/null 2>&1 || (${git} init -q && printf '${exclude}\\n' > ${JSON.stringify(`${root}/.xuda_code.git/info/exclude`)})`;
|
|
2415
|
+
// -c user.* so a box with no git identity still commits. --allow-empty because an
|
|
2416
|
+
// unchanged tree between two runs is normal and must not fail the snapshot.
|
|
2417
|
+
const commit = `${git} add -A >/dev/null 2>&1; ${git} -c user.email=code-run@xuda -c user.name=xuda commit -q --allow-empty -m ${JSON.stringify(`before ${run._id}`)} >/dev/null 2>&1; ${git} rev-parse HEAD`;
|
|
2418
|
+
const ret = await code_run_exec(run, `${init} && ${commit}`, 120000);
|
|
2419
|
+
const sha = (ret.stdout || '').trim().split(/\s+/).pop();
|
|
2420
|
+
if (ret.exit_code === 0 && /^[0-9a-f]{7,40}$/.test(sha || '')) {
|
|
2421
|
+
roots.push({ root, kind: 'git', ref: sha });
|
|
2422
|
+
continue;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Size guard first: a copy of a docroot with a bundled node_modules is not something to
|
|
2427
|
+
// start behind the user's back on their own disk.
|
|
2428
|
+
const size_kb = Number(((await code_run_exec(run, `du -sk ${JSON.stringify(root)} 2>/dev/null | cut -f1`, 60000)).stdout || '').trim()) || 0;
|
|
2429
|
+
if (size_kb * 1024 > CODE_RUN_COPY_MAX_BYTES) {
|
|
2430
|
+
roots.push({ root, kind: 'skipped', reason: 'too_large', size_kb });
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// --reflink=auto is GNU coreutils and makes this nearly free on a copy-on-write filesystem,
|
|
2435
|
+
// but it is not portable (BSD cp and busybox both reject the flag), so a plain -a is the
|
|
2436
|
+
// second try rather than the only one. The rm between them keeps a half-written first
|
|
2437
|
+
// attempt from being mistaken for the before state.
|
|
2438
|
+
const backup = path.join(code_run_backup_dir(run._id), root.replace(/[^a-zA-Z0-9]+/g, '_'));
|
|
2439
|
+
const copy = `cp -a --reflink=auto ${JSON.stringify(root)} ${JSON.stringify(backup)} 2>/dev/null || (rm -rf ${JSON.stringify(backup)} && cp -a ${JSON.stringify(root)} ${JSON.stringify(backup)})`;
|
|
2440
|
+
const ret = await code_run_exec(run, `mkdir -p ${JSON.stringify(path.dirname(backup))} && rm -rf ${JSON.stringify(backup)} && (${copy})`, 300000);
|
|
2441
|
+
if (ret.exit_code === 0) roots.push({ root, kind: 'copy', ref: backup });
|
|
2442
|
+
else roots.push({ root, kind: 'skipped', reason: 'copy_failed' });
|
|
2443
|
+
}
|
|
2444
|
+
} catch (err) {
|
|
2445
|
+
console.error(`[code_run] snapshot ${run._id} failed: ${err?.message || err}`);
|
|
2446
|
+
}
|
|
2447
|
+
run.snapshot = { roots, taken_ts: Date.now(), watched: run.watch_paths || [] };
|
|
2448
|
+
return run.snapshot;
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
const CODE_RUN_MAX_FILES = 400;
|
|
2452
|
+
const code_run_capture_after = async function (run) {
|
|
2453
|
+
const files = [];
|
|
2454
|
+
for (const entry of run.snapshot?.roots || []) {
|
|
2455
|
+
try {
|
|
2456
|
+
if (entry.kind === 'skipped') continue;
|
|
2457
|
+
|
|
2458
|
+
// UI-226, the repository case: what the run changed is what `git status` says, minus whatever
|
|
2459
|
+
// was already dirty when it started. Nothing is committed here, which is the point: the user
|
|
2460
|
+
// reviews the change set and commits it themselves, the way Claude Code works.
|
|
2461
|
+
if (entry.kind === 'repo') {
|
|
2462
|
+
const token = run._repo_token || null;
|
|
2463
|
+
const status = await git_exec({ cwd: entry.root, args: ['status', '--porcelain', '--untracked-files=all'], token });
|
|
2464
|
+
const pre = new Set(entry.pre_dirty || []);
|
|
2465
|
+
for (const line of (status.stdout || '').split('\n')) {
|
|
2466
|
+
const parsed = git_parse_status_line(line);
|
|
2467
|
+
if (!parsed) continue;
|
|
2468
|
+
const { code, rel } = parsed;
|
|
2469
|
+
if (pre.has(rel)) continue;
|
|
2470
|
+
const change = code.includes('D') ? 'delete' : code.includes('?') || code.includes('A') ? 'add' : 'edit';
|
|
2471
|
+
files.push({
|
|
2472
|
+
path: path.join(entry.root, rel),
|
|
2473
|
+
change,
|
|
2474
|
+
root: entry.root,
|
|
2475
|
+
rel,
|
|
2476
|
+
snapshot_kind: 'repo',
|
|
2477
|
+
before_ref: entry.ref,
|
|
2478
|
+
diff_available: true,
|
|
2479
|
+
revert_available: true,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (entry.kind === 'git') {
|
|
2486
|
+
const git = code_run_shadow_git(entry.root);
|
|
2487
|
+
const commit = `${git} add -A >/dev/null 2>&1; ${git} -c user.email=code-run@xuda -c user.name=xuda commit -q --allow-empty -m ${JSON.stringify(`after ${run._id}`)} >/dev/null 2>&1; ${git} rev-parse HEAD`;
|
|
2488
|
+
const after = ((await code_run_exec(run, commit, 120000)).stdout || '').trim().split(/\s+/).pop();
|
|
2489
|
+
const ret = await code_run_exec(run, `${git} diff --name-status ${entry.ref} ${after}`, 60000);
|
|
2490
|
+
for (const line of (ret.stdout || '').split('\n')) {
|
|
2491
|
+
const [status, rel] = line.trim().split(/\s+/);
|
|
2492
|
+
if (!status || !rel) continue;
|
|
2493
|
+
files.push({
|
|
2494
|
+
path: `${entry.root}/${rel}`.replace(/\/+/g, '/'),
|
|
2495
|
+
change: status.startsWith('A') ? 'add' : status.startsWith('D') ? 'delete' : 'edit',
|
|
2496
|
+
root: entry.root,
|
|
2497
|
+
rel,
|
|
2498
|
+
snapshot_kind: 'git',
|
|
2499
|
+
before_ref: entry.ref,
|
|
2500
|
+
after_ref: after,
|
|
2501
|
+
diff_available: true,
|
|
2502
|
+
revert_available: true,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
entry.after_ref = after;
|
|
2506
|
+
} else {
|
|
2507
|
+
// diff -qr names what changed on both sides; "Only in <dir>" covers adds and deletes.
|
|
2508
|
+
const ret = await code_run_exec(run, `diff -qr ${JSON.stringify(entry.ref)} ${JSON.stringify(entry.root)} 2>/dev/null | head -${CODE_RUN_MAX_FILES}`, 120000);
|
|
2509
|
+
for (const line of (ret.stdout || '').split('\n')) {
|
|
2510
|
+
const differ = line.match(/^Files (.+) and (.+) differ$/);
|
|
2511
|
+
const only = line.match(/^Only in (.+): (.+)$/);
|
|
2512
|
+
if (differ) {
|
|
2513
|
+
const abs = differ[2];
|
|
2514
|
+
files.push({ path: abs, change: 'edit', root: entry.root, rel: path.relative(entry.root, abs), snapshot_kind: 'copy', before_ref: entry.ref, diff_available: true, revert_available: true });
|
|
2515
|
+
} else if (only) {
|
|
2516
|
+
const abs = path.join(only[1], only[2]);
|
|
2517
|
+
const in_backup = only[1].startsWith(entry.ref);
|
|
2518
|
+
files.push({
|
|
2519
|
+
path: in_backup ? path.join(entry.root, path.relative(entry.ref, abs)) : abs,
|
|
2520
|
+
change: in_backup ? 'delete' : 'add',
|
|
2521
|
+
root: entry.root,
|
|
2522
|
+
rel: path.relative(in_backup ? entry.ref : entry.root, abs),
|
|
2523
|
+
snapshot_kind: 'copy',
|
|
2524
|
+
before_ref: entry.ref,
|
|
2525
|
+
diff_available: !in_backup,
|
|
2526
|
+
revert_available: true,
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
console.error(`[code_run] file diff ${run._id} ${entry.root} failed: ${err?.message || err}`);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
run.files = files.slice(0, CODE_RUN_MAX_FILES);
|
|
2536
|
+
run.files_truncated = files.length > CODE_RUN_MAX_FILES;
|
|
2537
|
+
return run.files;
|
|
2538
|
+
};
|
|
2539
|
+
|
|
2540
|
+
// ── Live view of a run ─────────────────────────────────────────────────────────────────────
|
|
2541
|
+
// Same events, same field names as the inline path, so the browser needs no new protocol: this
|
|
2542
|
+
// is a second producer of the stream AiChat.vue already renders.
|
|
2543
|
+
const code_run_emitter = function (run) {
|
|
2544
|
+
return function (type, content, params) {
|
|
2545
|
+
const is_stream_delta = type === 'stream_delta';
|
|
2546
|
+
const is_stream_end = type === 'stream_end';
|
|
2547
|
+
const seq = is_stream_delta ? ++run._delta_seq : undefined;
|
|
2548
|
+
if (is_stream_delta) run._delta_text += content || '';
|
|
2549
|
+
|
|
2550
|
+
ws_dashboard_msa.emit_message_to_dashboard({
|
|
2551
|
+
service: type,
|
|
2552
|
+
to: run.uid,
|
|
2553
|
+
data: {
|
|
2554
|
+
conversation_id: run.conversation_id,
|
|
2555
|
+
job_id: run.job_id,
|
|
2556
|
+
code_run_id: run._id,
|
|
2557
|
+
delta: content,
|
|
2558
|
+
...(type === 'stream_start' ? { request_started_at: run.started_ts } : {}),
|
|
2559
|
+
...(seq ? { seq } : {}),
|
|
2560
|
+
...(is_stream_end
|
|
2561
|
+
? {
|
|
2562
|
+
stream_id: run.response_conversation_item_id,
|
|
2563
|
+
final_seq: run._delta_seq,
|
|
2564
|
+
text: typeof params?.text === 'string' ? params.text : run._delta_text,
|
|
2565
|
+
}
|
|
2566
|
+
: {}),
|
|
2567
|
+
prompt_conversation_item_id: run.prompt_conversation_item_id,
|
|
2568
|
+
response_conversation_item_id: run.response_conversation_item_id,
|
|
2569
|
+
params,
|
|
2570
|
+
},
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
if (is_stream_end) {
|
|
2574
|
+
notify_chat_finished({
|
|
2575
|
+
uid: run.uid,
|
|
2576
|
+
conversation_id: run.conversation_id,
|
|
2577
|
+
conversation_doc: run._conversation_doc,
|
|
2578
|
+
text: typeof params?.text === 'string' ? params.text : run._delta_text,
|
|
2579
|
+
failed: !!(params?.error || params?.aborted),
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
};
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2585
|
+
// Codex JSONL → what the user sees and what the run remembers. The inline path has its own copy
|
|
2586
|
+
// of this mapping (handleCodexEvent inside execute_codex_request) which stays for the blocking
|
|
2587
|
+
// callers (generate_site_draft); this one is the durable version and additionally records the
|
|
2588
|
+
// session id, the commands and the agent's own words onto the run doc.
|
|
2589
|
+
const handle_code_run_event = function (run, emit, event) {
|
|
2590
|
+
if (!event || typeof event !== 'object') return;
|
|
2591
|
+
|
|
2592
|
+
switch (event.type) {
|
|
2593
|
+
case 'thread.started':
|
|
2594
|
+
// The thread id is what makes the NEXT turn a continuation instead of a cold start.
|
|
2595
|
+
if (event.thread_id && !run.codex_session_id) run.codex_session_id = event.thread_id;
|
|
2596
|
+
emit('stream_phase', 'Starting Codex thread', { update: true, thread_id: event.thread_id });
|
|
2597
|
+
break;
|
|
2598
|
+
case 'turn.started':
|
|
2599
|
+
emit('stream_phase', 'Working through the request', { update: true });
|
|
2600
|
+
break;
|
|
2601
|
+
case 'item.started': {
|
|
2602
|
+
const item = event.item || {};
|
|
2603
|
+
if (item.type === 'command_execution') {
|
|
2604
|
+
emit('stream_phase', 'Running command', { update: true, command: item.command || '' });
|
|
2605
|
+
}
|
|
2606
|
+
break;
|
|
2607
|
+
}
|
|
2608
|
+
case 'item.completed': {
|
|
2609
|
+
const item = event.item || {};
|
|
2610
|
+
if (item.type === 'agent_message' && item.text) {
|
|
2611
|
+
// Streamed as it happens, which is the difference between watching a build and watching
|
|
2612
|
+
// a spinner. The persisted answer is this same accumulated text, so the client's
|
|
2613
|
+
// reconciliation (UI-219) can never disagree with what it displayed.
|
|
2614
|
+
if (!run._seen_messages.has(item.id)) {
|
|
2615
|
+
run._seen_messages.add(item.id);
|
|
2616
|
+
if (!run._response_started) {
|
|
2617
|
+
run._response_started = true;
|
|
2618
|
+
emit('response_start');
|
|
2619
|
+
}
|
|
2620
|
+
const text = `${item.text.trim()}\n\n`;
|
|
2621
|
+
for (let i = 0; i < text.length; i += 280) emit('stream_delta', text.slice(i, i + 280));
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (item.type === 'command_execution') {
|
|
2625
|
+
const output = (item.aggregated_output || '').trim();
|
|
2626
|
+
run.commands.push({
|
|
2627
|
+
command: item.command || '',
|
|
2628
|
+
exit_code: item.exit_code,
|
|
2629
|
+
status: item.status,
|
|
2630
|
+
ts: Date.now(),
|
|
2631
|
+
output: output.length > 2000 ? `${output.slice(0, 2000)}...` : output,
|
|
2632
|
+
});
|
|
2633
|
+
if (run.commands.length > 200) run.commands.splice(0, run.commands.length - 200);
|
|
2634
|
+
emit('stream_phase', item.exit_code === 0 ? 'Command finished' : 'Command failed', { update: true, command: item.command || '', exit_code: item.exit_code });
|
|
2635
|
+
}
|
|
2636
|
+
break;
|
|
2637
|
+
}
|
|
2638
|
+
case 'error':
|
|
2639
|
+
run.error = event.message || 'codex error';
|
|
2640
|
+
emit('stream_phase', 'Something went wrong', { update: true, error: true });
|
|
2641
|
+
break;
|
|
2642
|
+
case 'turn.completed':
|
|
2643
|
+
run.usage = normalize_codex_usage(event.usage);
|
|
2644
|
+
break;
|
|
2645
|
+
case 'turn.failed':
|
|
2646
|
+
run.error = event.error?.message || 'codex turn failed';
|
|
2647
|
+
break;
|
|
2648
|
+
default:
|
|
2649
|
+
break;
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
|
|
2653
|
+
// ── The tail ───────────────────────────────────────────────────────────────────────────────
|
|
2654
|
+
const _code_run_tails = new Map(); // run_id -> stop()
|
|
2655
|
+
|
|
2656
|
+
const code_run_process_alive = function (pid) {
|
|
2657
|
+
if (!pid) return false;
|
|
2658
|
+
try {
|
|
2659
|
+
process.kill(pid, 0);
|
|
2660
|
+
return true;
|
|
2661
|
+
} catch (err) {
|
|
2662
|
+
return err.code === 'EPERM';
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
|
|
2666
|
+
const kill_code_run_process = function (pid) {
|
|
2667
|
+
if (!pid) return;
|
|
2668
|
+
try {
|
|
2669
|
+
// Negative pid: the whole group, because the Codex CLI is a shim that spawns the real binary
|
|
2670
|
+
// (the same reason run_process kills by group).
|
|
2671
|
+
process.kill(-pid, 'SIGKILL');
|
|
2672
|
+
} catch (err) {
|
|
2673
|
+
try {
|
|
2674
|
+
process.kill(pid, 'SIGKILL');
|
|
2675
|
+
} catch (e) {}
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2678
|
+
|
|
2679
|
+
const attach_code_run = function (run) {
|
|
2680
|
+
if (_code_run_tails.has(run._id)) return;
|
|
2681
|
+
|
|
2682
|
+
run._delta_seq = run._delta_seq || 0;
|
|
2683
|
+
run._delta_text = run._delta_text || '';
|
|
2684
|
+
run._seen_messages = run._seen_messages || new Set();
|
|
2685
|
+
run._response_started = !!run._response_started;
|
|
2686
|
+
run.commands = Array.isArray(run.commands) ? run.commands : [];
|
|
2687
|
+
|
|
2688
|
+
const emit = code_run_emitter(run);
|
|
2689
|
+
const parser = create_codex_jsonl_stream_parser((event) => {
|
|
2690
|
+
try {
|
|
2691
|
+
handle_code_run_event(run, emit, event);
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
console.error(`[code_run] event handling ${run._id}: ${err?.message || err}`);
|
|
2694
|
+
}
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
let offset = run.log_offset || 0;
|
|
2698
|
+
let last_flush = 0;
|
|
2699
|
+
let last_abort_check = 0;
|
|
2700
|
+
let last_heartbeat = Date.now();
|
|
2701
|
+
let ticking = false;
|
|
2702
|
+
let dead_ticks = 0;
|
|
2703
|
+
let finished = false;
|
|
2704
|
+
|
|
2705
|
+
const stop = () => {
|
|
2706
|
+
clearInterval(timer);
|
|
2707
|
+
_code_run_tails.delete(run._id);
|
|
2708
|
+
};
|
|
2709
|
+
|
|
2710
|
+
const tick = async () => {
|
|
2711
|
+
if (ticking || finished) return;
|
|
2712
|
+
ticking = true;
|
|
2713
|
+
try {
|
|
2714
|
+
// 1. drain whatever the run has written since last time
|
|
2715
|
+
let size = 0;
|
|
2716
|
+
try {
|
|
2717
|
+
size = (await fs.promises.stat(run.log_path)).size;
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
size = offset;
|
|
2720
|
+
}
|
|
2721
|
+
if (size > offset) {
|
|
2722
|
+
const length = Math.min(size - offset, CODE_RUN_READ_CHUNK);
|
|
2723
|
+
const fh = await fs.promises.open(run.log_path, 'r');
|
|
2724
|
+
try {
|
|
2725
|
+
const buf = Buffer.alloc(length);
|
|
2726
|
+
await fh.read(buf, 0, length, offset);
|
|
2727
|
+
offset += length;
|
|
2728
|
+
run.log_offset = offset;
|
|
2729
|
+
parser(buf.toString('utf8'));
|
|
2730
|
+
} finally {
|
|
2731
|
+
await fh.close();
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// 2. the user pressed Stop. The job doc carries the flag; the run owns the process. Checked
|
|
2736
|
+
// on a slower beat than the log read, since it is a cache round trip per check.
|
|
2737
|
+
if (run.job_id && Date.now() - last_abort_check > 2000) {
|
|
2738
|
+
last_abort_check = Date.now();
|
|
2739
|
+
if (await is_job_aborted(run.job_id)) {
|
|
2740
|
+
kill_code_run_process(run.pid);
|
|
2741
|
+
finished = true;
|
|
2742
|
+
stop();
|
|
2743
|
+
await finalize_code_run(run, { aborted: true });
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// 2b. keep the job alive. A job doc lives in memcached with a 600s TTL refreshed on every
|
|
2749
|
+
// write, and a background run writes nothing to it for the length of the run. Once it
|
|
2750
|
+
// expires, Stop answers "already finished" and the browser gives up on a run that is
|
|
2751
|
+
// still working (the same reconciliation UI-219 added, firing on a false signal).
|
|
2752
|
+
if (run.job_id && Date.now() - last_heartbeat > 60000) {
|
|
2753
|
+
last_heartbeat = Date.now();
|
|
2754
|
+
const last_command = run.commands.length ? run.commands[run.commands.length - 1].command : '';
|
|
2755
|
+
try {
|
|
2756
|
+
await jobs_ms.update_job({ job_id: run.job_id, is_background: true, current_step_name: last_command ? `running: ${String(last_command).slice(0, 80)}` : 'streaming results' });
|
|
2757
|
+
} catch (err) {}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// 3. a run past its wall clock is killed by process group, so nothing is left holding an
|
|
2761
|
+
// OpenAI socket or an SSH session on the customer's box.
|
|
2762
|
+
if (Date.now() - (run.started_ts || 0) > (run.timeout_ms || CODE_RUN_TIMEOUT_MS)) {
|
|
2763
|
+
kill_code_run_process(run.pid);
|
|
2764
|
+
finished = true;
|
|
2765
|
+
stop();
|
|
2766
|
+
await finalize_code_run(run, { timed_out: true });
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// 4. process gone and log fully drained → finish. Two quiet ticks, because the process can
|
|
2771
|
+
// exit a beat before its last line is flushed to the file.
|
|
2772
|
+
if (!code_run_process_alive(run.pid) && offset >= size) {
|
|
2773
|
+
dead_ticks += 1;
|
|
2774
|
+
if (dead_ticks >= 2) {
|
|
2775
|
+
finished = true;
|
|
2776
|
+
stop();
|
|
2777
|
+
await finalize_code_run(run, {});
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
} else {
|
|
2781
|
+
dead_ticks = 0;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
if (Date.now() - last_flush > CODE_RUN_FLUSH_MS) {
|
|
2785
|
+
last_flush = Date.now();
|
|
2786
|
+
await persist_code_run(run);
|
|
2787
|
+
}
|
|
2788
|
+
} catch (err) {
|
|
2789
|
+
console.error(`[code_run] tail ${run._id}: ${err?.message || err}`);
|
|
2790
|
+
} finally {
|
|
2791
|
+
ticking = false;
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
|
|
2795
|
+
const timer = setInterval(tick, CODE_RUN_TAIL_MS);
|
|
2796
|
+
_code_run_tails.set(run._id, stop);
|
|
2797
|
+
tick();
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
// ── Finishing a run ────────────────────────────────────────────────────────────────────────
|
|
2801
|
+
// Everything the inline vibe branch used to do after its await: work out the answer, pull the
|
|
2802
|
+
// clarifying questions off it, record usage, close the conversation and the job, and tell the
|
|
2803
|
+
// browser. It lives here because a run can finish while nobody is on the other end of the
|
|
2804
|
+
// socket, or in a process that did not start it.
|
|
2805
|
+
const finalize_code_run = async function (run, { aborted = false, timed_out = false } = {}) {
|
|
2806
|
+
if (run._finalized) return;
|
|
2807
|
+
run._finalized = true;
|
|
2808
|
+
|
|
2809
|
+
const emit = code_run_emitter(run);
|
|
2810
|
+
try {
|
|
2811
|
+
// Anything still unread in the log belongs to this answer.
|
|
2812
|
+
try {
|
|
2813
|
+
const size = (await fs.promises.stat(run.log_path)).size;
|
|
2814
|
+
if (size > (run.log_offset || 0)) {
|
|
2815
|
+
const fh = await fs.promises.open(run.log_path, 'r');
|
|
2816
|
+
try {
|
|
2817
|
+
const length = size - run.log_offset;
|
|
2818
|
+
const buf = Buffer.alloc(length);
|
|
2819
|
+
await fh.read(buf, 0, length, run.log_offset);
|
|
2820
|
+
run.log_offset = size;
|
|
2821
|
+
const parser = create_codex_jsonl_stream_parser((event) => handle_code_run_event(run, emit, event));
|
|
2822
|
+
parser(buf.toString('utf8'));
|
|
2823
|
+
} finally {
|
|
2824
|
+
await fh.close();
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
} catch (err) {}
|
|
2828
|
+
|
|
2829
|
+
const failed = !!run.error || (!run._delta_text && !aborted);
|
|
2830
|
+
const raw_text = aborted
|
|
2831
|
+
? run._delta_text || 'Stopped.'
|
|
2832
|
+
: timed_out
|
|
2833
|
+
? `${run._delta_text || ''}\n\nThis run was stopped because it went past its time limit.`.trim()
|
|
2834
|
+
: run._delta_text || "I couldn't finish that request just now. Please try again in a moment.";
|
|
2835
|
+
|
|
2836
|
+
const { prose, questions } = extract_xuda_questions(raw_text);
|
|
2837
|
+
const response_text = prose || raw_text;
|
|
2838
|
+
|
|
2839
|
+
// Which files it touched. After the answer text is settled, because a snapshot diff on a
|
|
2840
|
+
// slow box must never be what decides whether the user gets their answer.
|
|
2841
|
+
if (!aborted) await code_run_capture_after(run);
|
|
2842
|
+
|
|
2843
|
+
if (run.usage && (run.usage.input_tokens || run.usage.output_tokens)) {
|
|
2844
|
+
try {
|
|
2845
|
+
const account_profile_info = await get_active_account_profile_info(run.uid, run.profile_id);
|
|
2846
|
+
account_msa.record_ai_usage(run.uid, run.usage.input_tokens, run.usage.output_tokens, 'code run', run.prompt_preview || '', run.usage_model || 'openai_codex_cli', { conversation_id: run.conversation_id, job_id: run.job_id, code_run_id: run._id, remote_host: run.remote_host || null }, account_profile_info);
|
|
2847
|
+
} catch (err) {
|
|
2848
|
+
console.error(`[code_run] usage ${run._id}: ${err?.message || err}`);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// The assistant's message, same shape the inline path saved.
|
|
2853
|
+
try {
|
|
2854
|
+
await db_module.save_app_couch_doc_native(run.app_id, {
|
|
2855
|
+
_id: run.response_conversation_item_id,
|
|
2856
|
+
stat: 3,
|
|
2857
|
+
docType: 'chat_conversation_item',
|
|
2858
|
+
uid: run.uid,
|
|
2859
|
+
conversation_type: 'dashboard',
|
|
2860
|
+
type: 'dashboard',
|
|
2861
|
+
date_created_ts: Date.now(),
|
|
2862
|
+
ts: Date.now(),
|
|
2863
|
+
conversation_id: run.conversation_id,
|
|
2864
|
+
text: response_text,
|
|
2865
|
+
reference_id: run.reference_id,
|
|
2866
|
+
direction: 'in',
|
|
2867
|
+
role: 'assistant',
|
|
2868
|
+
read: { [run.uid]: Date.now() },
|
|
2869
|
+
rtl: _common.detectRTL(response_text),
|
|
2870
|
+
job_id: run.job_id,
|
|
2871
|
+
target_app_id: run.target_app_id,
|
|
2872
|
+
prompt_conversation_item_id: run.prompt_conversation_item_id,
|
|
2873
|
+
code_run_id: run._id,
|
|
2874
|
+
...(questions ? { questions } : {}),
|
|
2875
|
+
...(failed || timed_out ? { is_request_error: true } : {}),
|
|
2876
|
+
});
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
console.error(`[code_run] save item ${run._id}: ${err?.message || err}`);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// The conversation carries the Codex thread, so the next turn resumes instead of restarting.
|
|
2882
|
+
try {
|
|
2883
|
+
const conversation_doc = await db_module.get_app_couch_doc_native(run.app_id, run.conversation_id);
|
|
2884
|
+
conversation_doc.ts = Date.now();
|
|
2885
|
+
conversation_doc.stat = 3;
|
|
2886
|
+
conversation_doc.process_stat = failed || timed_out ? 'partial' : 'full';
|
|
2887
|
+
conversation_doc.conversation_item_id = run.response_conversation_item_id;
|
|
2888
|
+
if (run.codex_session_id) {
|
|
2889
|
+
conversation_doc.codex_session_id = run.codex_session_id;
|
|
2890
|
+
conversation_doc.codex_session_host = run.host;
|
|
2891
|
+
}
|
|
2892
|
+
await db_module.save_app_couch_doc_native(run.app_id, conversation_doc);
|
|
2893
|
+
run._conversation_doc = conversation_doc;
|
|
2894
|
+
} catch (err) {
|
|
2895
|
+
console.error(`[code_run] conversation ${run._id}: ${err?.message || err}`);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// The scoped pass this run held over MCP dies with the run, whatever the outcome.
|
|
2899
|
+
if (run.revoke_api_key) {
|
|
2900
|
+
try {
|
|
2901
|
+
await db_module.delete_couch_doc('xuda_api_keys', run.revoke_api_key);
|
|
2902
|
+
} catch (err) {}
|
|
2903
|
+
run.revoke_api_key = null;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
run.state = aborted ? 'aborted' : timed_out ? 'failed' : failed ? 'failed' : 'done';
|
|
2907
|
+
run.ended_ts = Date.now();
|
|
2908
|
+
if (timed_out && !run.error) run.error = 'timeout';
|
|
2909
|
+
await persist_code_run(run);
|
|
2910
|
+
|
|
2911
|
+
if (!run._response_started) emit('response_start');
|
|
2912
|
+
emit('stream_end', undefined, {
|
|
2913
|
+
text: response_text,
|
|
2914
|
+
...(questions ? { questions } : {}),
|
|
2915
|
+
...(aborted ? { aborted: true } : {}),
|
|
2916
|
+
...(failed || timed_out ? { error: true } : {}),
|
|
2917
|
+
code_run_id: run._id,
|
|
2918
|
+
files: (run.files || []).map((f) => ({ path: f.path, change: f.change })),
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
// The chat job was left open on purpose (a code run is longer than any job timeout), so it
|
|
2922
|
+
// is closed here rather than by whoever started it.
|
|
2923
|
+
if (run.job_id) {
|
|
2924
|
+
try {
|
|
2925
|
+
await jobs_ms.update_job({ job_id: run.job_id, response: { code: failed || timed_out ? -1 : 1, data: failed || timed_out ? run.error || 'code run failed' : 'ok' } });
|
|
2926
|
+
} catch (err) {}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// Let the next queued run in.
|
|
2930
|
+
start_queued_code_runs().catch(() => {});
|
|
2931
|
+
} catch (err) {
|
|
2932
|
+
console.error(`[code_run] finalize ${run._id}: ${err?.message || err}`);
|
|
2933
|
+
}
|
|
2934
|
+
};
|
|
2935
|
+
|
|
2936
|
+
// ── Starting a run ─────────────────────────────────────────────────────────────────────────
|
|
2937
|
+
// Returns as soon as the process is up (or queued), which is what takes the run off the chat
|
|
2938
|
+
// job's clock. `req`-shaped input, same fields execute_codex_request takes, so the two paths stay
|
|
2939
|
+
// recognisably the same thing.
|
|
2940
|
+
//
|
|
2941
|
+
// Exported because a run is startable from more than the chat turn that first needed it (a
|
|
2942
|
+
// re-run from the code workspace, and the broker probes the verification leans on).
|
|
2943
|
+
export const start_code_run = async function (opts) {
|
|
2944
|
+
const {
|
|
2945
|
+
uid,
|
|
2946
|
+
profile_id,
|
|
2947
|
+
app_id,
|
|
2948
|
+
target_app_id,
|
|
2949
|
+
conversation_id,
|
|
2950
|
+
conversation_doc,
|
|
2951
|
+
reference_id,
|
|
2952
|
+
job_id,
|
|
2953
|
+
prompt,
|
|
2954
|
+
prompt_conversation_item_id,
|
|
2955
|
+
response_conversation_item_id,
|
|
2956
|
+
ip,
|
|
2957
|
+
local_cwd,
|
|
2958
|
+
app_obj,
|
|
2959
|
+
codex_model,
|
|
2960
|
+
attachments = [],
|
|
2961
|
+
agent_mcp,
|
|
2962
|
+
bypass_sandbox = false,
|
|
2963
|
+
sandbox,
|
|
2964
|
+
timeout_ms,
|
|
2965
|
+
revoke_api_key,
|
|
2966
|
+
} = opts;
|
|
2967
|
+
|
|
2968
|
+
const run_id = await _common.xuda_get_uuid('code_run');
|
|
2969
|
+
const run = {
|
|
2970
|
+
_id: run_id,
|
|
2971
|
+
docType: 'code_run',
|
|
2972
|
+
stat: 3,
|
|
2973
|
+
engine: local_cwd ? 'codex_local' : 'codex_remote',
|
|
2974
|
+
state: 'queued',
|
|
2975
|
+
uid,
|
|
2976
|
+
profile_id,
|
|
2977
|
+
app_id,
|
|
2978
|
+
target_app_id,
|
|
2979
|
+
conversation_id,
|
|
2980
|
+
reference_id,
|
|
2981
|
+
job_id,
|
|
2982
|
+
prompt_conversation_item_id,
|
|
2983
|
+
response_conversation_item_id,
|
|
2984
|
+
host: code_run_host(),
|
|
2985
|
+
remote_host: local_cwd ? null : get_codex_remote_host(ip),
|
|
2986
|
+
local_cwd: local_cwd || null,
|
|
2987
|
+
log_path: code_run_log_path(run_id),
|
|
2988
|
+
log_offset: 0,
|
|
2989
|
+
// Config decides which directories a run watches, per app type. An explicit list overrides it
|
|
2990
|
+
// for the cases that know better than the catalog: a full stack VPS that declares its own
|
|
2991
|
+
// layout, and a probe that must not snapshot anything but its own scratch directory.
|
|
2992
|
+
watch_paths: Array.isArray(opts.watch_paths) ? opts.watch_paths : code_run_watch_paths(app_obj, local_cwd),
|
|
2993
|
+
codex_session_id: null,
|
|
2994
|
+
resumed_session_id: opts.resume_session_id || null,
|
|
2995
|
+
usage_model: codex_model || null,
|
|
2996
|
+
// An ephemeral scoped MCP key the run is still using. It used to be revoked the moment the
|
|
2997
|
+
// codex call returned, which is now the moment the run STARTS, so the run revokes it instead.
|
|
2998
|
+
revoke_api_key: revoke_api_key || null,
|
|
2999
|
+
prompt_preview: (prompt || '').slice(0, 500),
|
|
3000
|
+
timeout_ms: timeout_ms || CODE_RUN_TIMEOUT_MS,
|
|
3001
|
+
emit_stream_start: opts.emit_stream_start === true,
|
|
3002
|
+
// UI-226: set only for a repository-backed run. Their presence is what switches the file capture
|
|
3003
|
+
// from a snapshot to the repository itself.
|
|
3004
|
+
repo_id: opts.repo_id || null,
|
|
3005
|
+
branch: opts.branch || null,
|
|
3006
|
+
base_sha: opts.base_sha || null,
|
|
3007
|
+
pre_dirty: Array.isArray(opts.pre_dirty) ? opts.pre_dirty : [],
|
|
3008
|
+
commands: [],
|
|
3009
|
+
files: [],
|
|
3010
|
+
date_created_ts: Date.now(),
|
|
3011
|
+
ts: Date.now(),
|
|
3012
|
+
_spawn: { prompt, codex_model, attachments, agent_mcp, bypass_sandbox, sandbox },
|
|
3013
|
+
};
|
|
3014
|
+
run._conversation_doc = conversation_doc;
|
|
3015
|
+
|
|
3016
|
+
await fs.promises.mkdir(code_run_dir(run_id), { recursive: true });
|
|
3017
|
+
// Identity on disk, so a restarted box can find this run again without memcached and without
|
|
3018
|
+
// scanning every account database. Written before the doc: if anything fails after this, the
|
|
3019
|
+
// sweep still knows the run existed.
|
|
3020
|
+
await fs.promises.writeFile(code_run_meta_path(run_id), JSON.stringify({ run_id, app_id, uid, conversation_id, host: run.host, engine: run.engine, log_path: run.log_path }, null, 2));
|
|
3021
|
+
|
|
3022
|
+
await persist_code_run(run);
|
|
3023
|
+
|
|
3024
|
+
if (_code_run_tails.size >= CODE_RUN_MAX_CONCURRENT) {
|
|
3025
|
+
// Over the cap: leave it queued and let a finishing run pull it in. A busy account degrades
|
|
3026
|
+
// into a queue instead of into swap, which is what unbounded concurrency did (one Codex CLI
|
|
3027
|
+
// plus one SSH session per run, all in the ai process).
|
|
3028
|
+
_code_run_queue.push(run);
|
|
3029
|
+
return { code: 1, data: { run_id, state: 'queued' } };
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
return await launch_code_run(run);
|
|
3033
|
+
};
|
|
3034
|
+
|
|
3035
|
+
const _code_run_queue = [];
|
|
3036
|
+
|
|
3037
|
+
const launch_code_run = async function (run) {
|
|
3038
|
+
try {
|
|
3039
|
+
const { prompt, codex_model, attachments = [], agent_mcp, bypass_sandbox, sandbox } = run._spawn || {};
|
|
3040
|
+
let codex_args = get_openai_codex_exec_args(codex_model, { sandbox, bypass_sandbox, resume_session_id: run.resumed_session_id || undefined });
|
|
3041
|
+
if (agent_mcp && agent_mcp.url) {
|
|
3042
|
+
codex_args = ['-c', `mcp_servers.xuda.url="${agent_mcp.url}"`, '-c', `mcp_servers.xuda.bearer_token_env_var="${agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY'}"`, ...codex_args];
|
|
3043
|
+
}
|
|
3044
|
+
for (const attachment of attachments) {
|
|
3045
|
+
const local_attachment_path = get_codex_local_attachment_path(attachment);
|
|
3046
|
+
if (local_attachment_path && is_codex_image_attachment(local_attachment_path)) codex_args.push('--image', local_attachment_path);
|
|
3047
|
+
}
|
|
3048
|
+
// Positionals last, in order: `exec resume [OPTIONS] <SESSION_ID> [PROMPT]`. `-` is the prompt,
|
|
3049
|
+
// read from stdin.
|
|
3050
|
+
if (run.resumed_session_id) codex_args.push(run.resumed_session_id);
|
|
3051
|
+
codex_args.push('-');
|
|
3052
|
+
|
|
3053
|
+
// Before the process, never after: once Codex starts it can write within the same second.
|
|
3054
|
+
await code_run_capture_before(run);
|
|
3055
|
+
|
|
3056
|
+
const { pid, detach } = await spawn_detached_to_log(get_openai_codex_cli_command(), codex_args, prompt, {
|
|
3057
|
+
log_path: run.log_path,
|
|
3058
|
+
env: {
|
|
3059
|
+
...process.env,
|
|
3060
|
+
OPENAI_API_KEY: _conf.OPENAI_API_KEY,
|
|
3061
|
+
...(agent_mcp && agent_mcp.bearer_value ? { [agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY']: agent_mcp.bearer_value } : {}),
|
|
3062
|
+
},
|
|
3063
|
+
...(run.local_cwd ? { cwd: run.local_cwd } : {}),
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
run.pid = pid;
|
|
3067
|
+
run.detach = detach;
|
|
3068
|
+
run.state = 'running';
|
|
3069
|
+
run.started_ts = Date.now();
|
|
3070
|
+
delete run._spawn;
|
|
3071
|
+
await persist_code_run(run);
|
|
3072
|
+
|
|
3073
|
+
const emit = code_run_emitter(run);
|
|
3074
|
+
// Only when nobody has opened the stream yet. A chat turn already emitted stream_start (and
|
|
3075
|
+
// stamped request_started_at) before it got here, and a second one makes the client throw away
|
|
3076
|
+
// the bubble it just made.
|
|
3077
|
+
if (run.emit_stream_start) emit('stream_start');
|
|
3078
|
+
emit('stream_phase', run.resumed_session_id ? 'Picking up where we left off' : 'Starting', { update: true });
|
|
3079
|
+
|
|
3080
|
+
attach_code_run(run);
|
|
3081
|
+
return { code: 1, data: { run_id: run._id, pid, state: 'running' } };
|
|
3082
|
+
} catch (err) {
|
|
3083
|
+
console.error(`[code_run] launch ${run._id} failed: ${err?.message || err}`);
|
|
3084
|
+
run.state = 'failed';
|
|
3085
|
+
run.error = err?.message || String(err);
|
|
3086
|
+
run.ended_ts = Date.now();
|
|
3087
|
+
await persist_code_run(run);
|
|
3088
|
+
await finalize_code_run(run, {});
|
|
3089
|
+
return { code: -1, data: 'code run failed to start' };
|
|
3090
|
+
}
|
|
3091
|
+
};
|
|
3092
|
+
|
|
3093
|
+
const start_queued_code_runs = async function () {
|
|
3094
|
+
while (_code_run_queue.length && _code_run_tails.size < CODE_RUN_MAX_CONCURRENT) {
|
|
3095
|
+
const next = _code_run_queue.shift();
|
|
3096
|
+
if (!next) break;
|
|
3097
|
+
await launch_code_run(next);
|
|
3098
|
+
}
|
|
3099
|
+
};
|
|
3100
|
+
|
|
3101
|
+
// ── Surviving a restart ────────────────────────────────────────────────────────────────────
|
|
3102
|
+
// Deliberately deferred rather than run at import time: the module is still evaluating when the
|
|
3103
|
+
// broker loads it, and this reaches helpers defined further down the file.
|
|
3104
|
+
const resume_code_runs_after_boot = async function () {
|
|
3105
|
+
let dirs = [];
|
|
3106
|
+
try {
|
|
3107
|
+
dirs = await fs.promises.readdir(code_run_root_dir());
|
|
3108
|
+
} catch (err) {
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
for (const run_id of dirs) {
|
|
3113
|
+
try {
|
|
3114
|
+
const meta = JSON.parse(await fs.promises.readFile(code_run_meta_path(run_id), 'utf8'));
|
|
3115
|
+
if (!meta?.app_id || meta.host !== code_run_host()) continue;
|
|
3116
|
+
const run = await load_code_run(meta.app_id, run_id);
|
|
3117
|
+
if (!run || run.state !== 'running') continue;
|
|
3118
|
+
|
|
3119
|
+
if (code_run_process_alive(run.pid)) {
|
|
3120
|
+
// Still working. Pick the transcript back up where the last process left off, so the
|
|
3121
|
+
// answer still lands and the user sees the rest of it.
|
|
3122
|
+
console.log(`[code_run] re-attaching to ${run_id} (pid ${run.pid})`);
|
|
3123
|
+
attach_code_run(run);
|
|
3124
|
+
} else {
|
|
3125
|
+
// The process went with the restart (or was killed). Finish from what the log holds
|
|
3126
|
+
// rather than leaving a chat that spins forever.
|
|
3127
|
+
console.log(`[code_run] finalizing orphaned ${run_id}`);
|
|
3128
|
+
run.state = 'orphaned';
|
|
3129
|
+
await finalize_code_run(run, {});
|
|
3130
|
+
}
|
|
3131
|
+
} catch (err) {
|
|
3132
|
+
// A directory with no readable meta is not a run we can recover; the sweep removes it.
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
|
|
3137
|
+
// Retention + the cross-process backstop, called from the controller tick. Handles the case the
|
|
3138
|
+
// boot scan cannot: a box whose ai process never came back, and run directories nobody needs.
|
|
3139
|
+
export const sweep_code_runs = async function () {
|
|
3140
|
+
let removed = 0;
|
|
3141
|
+
let finalized = 0;
|
|
3142
|
+
try {
|
|
3143
|
+
const dirs = await fs.promises.readdir(code_run_root_dir()).catch(() => []);
|
|
3144
|
+
for (const run_id of dirs) {
|
|
3145
|
+
const dir = code_run_dir(run_id);
|
|
3146
|
+
let meta = null;
|
|
3147
|
+
try {
|
|
3148
|
+
meta = JSON.parse(await fs.promises.readFile(code_run_meta_path(run_id), 'utf8'));
|
|
3149
|
+
} catch (err) {}
|
|
3150
|
+
const stat = await fs.promises.stat(dir).catch(() => null);
|
|
3151
|
+
const age = stat ? Date.now() - stat.mtimeMs : 0;
|
|
3152
|
+
|
|
3153
|
+
if (meta?.app_id && meta.host === code_run_host()) {
|
|
3154
|
+
const run = await load_code_run(meta.app_id, run_id);
|
|
3155
|
+
if (run && run.state === 'running' && !_code_run_tails.has(run_id) && !code_run_process_alive(run.pid)) {
|
|
3156
|
+
run.state = 'orphaned';
|
|
3157
|
+
await finalize_code_run(run, {});
|
|
3158
|
+
finalized += 1;
|
|
3159
|
+
continue;
|
|
3160
|
+
}
|
|
3161
|
+
if (run && run.state === 'running') continue;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
if (age > CODE_RUN_RETENTION_MS) {
|
|
3165
|
+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3166
|
+
removed += 1;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
} catch (err) {
|
|
3170
|
+
console.error(`[code_run] sweep: ${err?.message || err}`);
|
|
3171
|
+
}
|
|
3172
|
+
return { code: 1, data: { removed, finalized, active: _code_run_tails.size, queued: _code_run_queue.length } };
|
|
3173
|
+
};
|
|
3174
|
+
|
|
3175
|
+
setTimeout(() => {
|
|
3176
|
+
resume_code_runs_after_boot().catch((err) => console.error(`[code_run] boot scan: ${err?.message || err}`));
|
|
3177
|
+
}, 5000);
|
|
3178
|
+
|
|
3179
|
+
// ── Reading a run, and undoing one file of it ───────────────────────────────────────────────
|
|
3180
|
+
// The workspace reads runs through these. Ownership needs no extra check beyond the database a
|
|
3181
|
+
// run is read FROM: runs live in the caller's own account project db, so another account's run is
|
|
3182
|
+
// simply not found (same wall get_chat_conversation stands behind). The uid comparison below is
|
|
3183
|
+
// belt and braces for a shared profile.
|
|
3184
|
+
const load_own_code_run = async function (uid, profile_id, run_id) {
|
|
3185
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3186
|
+
const run = await load_code_run(account_profile_info.app_id, run_id);
|
|
3187
|
+
if (!run) return { error: { code: -1, data: 'code run not found' } };
|
|
3188
|
+
if (run.uid && run.uid !== uid) return { error: { code: -1, data: 'code run not found' } };
|
|
3189
|
+
return { run, app_id: account_profile_info.app_id };
|
|
3190
|
+
};
|
|
3191
|
+
|
|
3192
|
+
// One file's worth of change, as a unified diff. Both snapshot mechanisms can answer it: git from
|
|
3193
|
+
// the two commits it made, a copy snapshot by diffing the backup against the live tree. Output is
|
|
3194
|
+
// capped because a diff is for reading, not for shipping a binary through a socket.
|
|
3195
|
+
const CODE_RUN_DIFF_MAX_LINES = 2000;
|
|
3196
|
+
const code_run_file_entry = function (run, file_path) {
|
|
3197
|
+
return (run.files || []).find((f) => f.path === file_path || f.rel === file_path) || null;
|
|
3198
|
+
};
|
|
3199
|
+
|
|
3200
|
+
// One line of `git status --porcelain` into { code, rel }. Deliberately NOT a fixed slice at column
|
|
3201
|
+
// 3: the runner trims a command's output, which removes the leading space of the FIRST line only, so
|
|
3202
|
+
// a fixed offset silently ate the first character of the first path (`app.js` came back as `pp.js`,
|
|
3203
|
+
// and every diff and revert for it then found nothing). A rename reports `old -> new`; the new path
|
|
3204
|
+
// is the one that exists.
|
|
3205
|
+
const git_parse_status_line = function (line) {
|
|
3206
|
+
const m = String(line || '').match(/^\s*(\S{1,2})\s+(.+)$/);
|
|
3207
|
+
if (!m) return null;
|
|
3208
|
+
const rel = m[2].includes(' -> ') ? m[2].split(' -> ').pop() : m[2];
|
|
3209
|
+
return { code: m[1], rel: rel.replace(/^"|"$/g, '') };
|
|
3210
|
+
};
|
|
3211
|
+
|
|
3212
|
+
// The token a repo-backed run's git commands need. Read from the repo doc at use time rather than
|
|
3213
|
+
// stored on the run, so a disconnected repository takes its access with it.
|
|
3214
|
+
const git_run_token = async function (uid, profile_id, run) {
|
|
3215
|
+
if (!run?.repo_id) return null;
|
|
3216
|
+
const { repo } = await load_git_repo(uid, profile_id, run.repo_id);
|
|
3217
|
+
return repo?.token || null;
|
|
3218
|
+
};
|
|
3219
|
+
|
|
3220
|
+
export const code_run_file_diff = async function (req) {
|
|
3221
|
+
const { uid, profile_id, run_id, path: file_path } = req;
|
|
3222
|
+
try {
|
|
3223
|
+
const { run, error } = await load_own_code_run(uid, profile_id, run_id);
|
|
3224
|
+
if (error) return error;
|
|
3225
|
+
const entry = code_run_file_entry(run, file_path);
|
|
3226
|
+
if (!entry) return { code: -1, data: 'that file is not part of this run' };
|
|
3227
|
+
if (!entry.diff_available) return { code: 2, data: { path: entry.path, change: entry.change, diff: '', reason: 'no before state was captured for this file' } };
|
|
3228
|
+
|
|
3229
|
+
// UI-226: a repository's change is read from the repository. Uncommitted work diffs against HEAD;
|
|
3230
|
+
// once the user has committed it, the run's own base_sha is what the change is still measured
|
|
3231
|
+
// from, so the panel keeps showing what THIS run did rather than nothing at all.
|
|
3232
|
+
if (entry.snapshot_kind === 'repo') {
|
|
3233
|
+
const token = await git_run_token(uid, profile_id, run);
|
|
3234
|
+
const dirty = await git_exec({ cwd: entry.root, args: ['status', '--porcelain', '--untracked-files=all', '--', entry.rel], token });
|
|
3235
|
+
const args = dirty.stdout
|
|
3236
|
+
? entry.change === 'add'
|
|
3237
|
+
? ['diff', '--no-index', '--', '/dev/null', entry.rel]
|
|
3238
|
+
: ['diff', 'HEAD', '--', entry.rel]
|
|
3239
|
+
: ['diff', `${entry.before_ref}..HEAD`, '--', entry.rel];
|
|
3240
|
+
const ret = await git_exec({ cwd: entry.root, args, token });
|
|
3241
|
+
const diff = (ret.stdout || '').split('\n').slice(0, CODE_RUN_DIFF_MAX_LINES).join('\n');
|
|
3242
|
+
return { code: 1, data: { path: entry.path, change: entry.change, diff, truncated: (ret.stdout || '').split('\n').length > CODE_RUN_DIFF_MAX_LINES } };
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
let cmd;
|
|
3246
|
+
if (entry.snapshot_kind === 'git') {
|
|
3247
|
+
const git = code_run_shadow_git(entry.root);
|
|
3248
|
+
cmd = `${git} diff ${entry.before_ref} ${entry.after_ref || 'HEAD'} -- ${JSON.stringify(entry.rel)} | head -${CODE_RUN_DIFF_MAX_LINES}`;
|
|
3249
|
+
} else {
|
|
3250
|
+
const before = path.join(entry.before_ref, entry.rel);
|
|
3251
|
+
// /dev/null on the side that does not exist, so an add and a delete both read as a diff
|
|
3252
|
+
// rather than as an error.
|
|
3253
|
+
const left = entry.change === 'add' ? '/dev/null' : JSON.stringify(before);
|
|
3254
|
+
const right = entry.change === 'delete' ? '/dev/null' : JSON.stringify(entry.path);
|
|
3255
|
+
cmd = `diff -u ${left} ${right} | head -${CODE_RUN_DIFF_MAX_LINES}`;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
const ret = await code_run_exec(run, cmd, 60000);
|
|
3259
|
+
const diff = ret.stdout || '';
|
|
3260
|
+
return { code: 1, data: { path: entry.path, change: entry.change, diff, truncated: diff.split('\n').length >= CODE_RUN_DIFF_MAX_LINES } };
|
|
3261
|
+
} catch (err) {
|
|
3262
|
+
console.error(`[code_run] diff failed: ${err?.message || err}`);
|
|
3263
|
+
return { code: -1, data: 'could not read that change' };
|
|
3264
|
+
}
|
|
3265
|
+
};
|
|
3266
|
+
|
|
3267
|
+
// Put one file back the way it was before the run. This WRITES on the customer's own machine, so
|
|
3268
|
+
// it carries the same 'files' consent the file tools do (predicate duplicated from app_module the
|
|
3269
|
+
// way _ask_ai_consent_blocked above is, since no cpi module imports app_module), and it refuses
|
|
3270
|
+
// rather than guesses when the snapshot it would restore from is gone.
|
|
3271
|
+
const _CODE_RUN_CONSENT_TYPES = ['vps', 'external_vps'];
|
|
3272
|
+
const _code_run_files_consent_blocked = (app_obj) =>
|
|
3273
|
+
!!app_obj && _CODE_RUN_CONSENT_TYPES.includes(app_obj.app_type) && app_obj?.deploy_data?.access_consents?.files?.granted !== true;
|
|
3274
|
+
|
|
3275
|
+
export const code_run_revert_file = async function (req) {
|
|
3276
|
+
const { uid, profile_id, run_id, path: file_path } = req;
|
|
3277
|
+
try {
|
|
3278
|
+
const { run, error } = await load_own_code_run(uid, profile_id, run_id);
|
|
3279
|
+
if (error) return error;
|
|
3280
|
+
const entry = code_run_file_entry(run, file_path);
|
|
3281
|
+
if (!entry) return { code: -1, data: 'that file is not part of this run' };
|
|
3282
|
+
if (!entry.revert_available) return { code: -1, data: 'there is no saved copy of this file from before the run, so it cannot be put back' };
|
|
3283
|
+
if (entry.reverted_ts) return { code: 2, data: { path: entry.path, already_reverted: true } };
|
|
3284
|
+
|
|
3285
|
+
if (run.remote_host && run.target_app_id) {
|
|
3286
|
+
let app_obj = null;
|
|
3287
|
+
try {
|
|
3288
|
+
app_obj = (await db_module.get_couch_doc('xuda_master', run.target_app_id)).data;
|
|
3289
|
+
} catch (e) {}
|
|
3290
|
+
if (_code_run_files_consent_blocked(app_obj)) {
|
|
3291
|
+
return { code: -91, data: 'File access consent required for this server', consent_required: true, scope: 'files' };
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
// UI-226: in a repository, putting a file back is what git already means by it. An untracked file
|
|
3296
|
+
// the run created is removed; anything else is restored from HEAD (or from the run's base when the
|
|
3297
|
+
// work has since been committed).
|
|
3298
|
+
if (entry.snapshot_kind === 'repo') {
|
|
3299
|
+
const token = await git_run_token(uid, profile_id, run);
|
|
3300
|
+
const tracked = await git_exec({ cwd: entry.root, args: ['ls-files', '--error-unmatch', '--', entry.rel], token });
|
|
3301
|
+
const args = tracked.exit_code !== 0 ? null : ['checkout', 'HEAD', '--', entry.rel];
|
|
3302
|
+
const ret = args ? await git_exec({ cwd: entry.root, args, token }) : await run_process('bash', ['-lc', `rm -f ${JSON.stringify(entry.path)}`], null, { timeout: 30000 });
|
|
3303
|
+
if (ret.exit_code !== 0) {
|
|
3304
|
+
console.error(`[code_run] repo revert ${run_id} ${entry.rel} exit ${ret.exit_code}`);
|
|
3305
|
+
return { code: -1, data: 'could not put that file back' };
|
|
3306
|
+
}
|
|
3307
|
+
entry.reverted_ts = Date.now();
|
|
3308
|
+
entry.reverted_by_uid = uid;
|
|
3309
|
+
await persist_code_run(run);
|
|
3310
|
+
return { code: 1, data: { path: entry.path, change: entry.change, reverted: true } };
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
let cmd;
|
|
3314
|
+
if (entry.snapshot_kind === 'git') {
|
|
3315
|
+
const git = code_run_shadow_git(entry.root);
|
|
3316
|
+
// A file the run CREATED has nothing to check out: putting it back means removing it.
|
|
3317
|
+
cmd = entry.change === 'add' ? `rm -f ${JSON.stringify(entry.path)}` : `${git} checkout ${entry.before_ref} -- ${JSON.stringify(entry.rel)}`;
|
|
3318
|
+
} else {
|
|
3319
|
+
const before = path.join(entry.before_ref, entry.rel);
|
|
3320
|
+
cmd = entry.change === 'add' ? `rm -f ${JSON.stringify(entry.path)}` : `mkdir -p ${JSON.stringify(path.dirname(entry.path))} && cp -a ${JSON.stringify(before)} ${JSON.stringify(entry.path)}`;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
const ret = await code_run_exec(run, cmd, 60000);
|
|
3324
|
+
if (ret.exit_code !== 0) {
|
|
3325
|
+
console.error(`[code_run] revert ${run_id} ${entry.path} exit ${ret.exit_code}: ${(ret.stderr || '').slice(0, 300)}`);
|
|
3326
|
+
return { code: -1, data: 'could not put that file back' };
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
entry.reverted_ts = Date.now();
|
|
3330
|
+
entry.reverted_by_uid = uid;
|
|
3331
|
+
await persist_code_run(run);
|
|
3332
|
+
return { code: 1, data: { path: entry.path, change: entry.change, reverted: true } };
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
console.error(`[code_run] revert failed: ${err?.message || err}`);
|
|
3335
|
+
return { code: -1, data: 'could not put that file back' };
|
|
3336
|
+
}
|
|
3337
|
+
};
|
|
3338
|
+
|
|
3339
|
+
// The runs behind a conversation (the workspace's timeline), or the account's most recent ones.
|
|
3340
|
+
export const get_code_runs = async function (req) {
|
|
3341
|
+
const { uid, profile_id, conversation_id, limit = 50 } = req;
|
|
3342
|
+
try {
|
|
3343
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3344
|
+
const selector = { docType: 'code_run', uid };
|
|
3345
|
+
if (conversation_id) selector.conversation_id = conversation_id;
|
|
3346
|
+
// The window is deliberately wider than the caller's limit, and the ordering is applied here
|
|
3347
|
+
// rather than in the query. Mango applies `limit` BEFORE anything is sorted and a `sort` on
|
|
3348
|
+
// date_created_ts would need its own index, so asking the database for 1 row returned whichever
|
|
3349
|
+
// run it happened to find first: the run panel asks for the newest and was handed an old one.
|
|
3350
|
+
const window_size = Math.max(Number(limit) || 1, 25);
|
|
3351
|
+
const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector, limit: window_size });
|
|
3352
|
+
const docs = (q?.docs || []).sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0)).slice(0, Number(limit) || 1);
|
|
3353
|
+
return {
|
|
3354
|
+
code: 1,
|
|
3355
|
+
data: {
|
|
3356
|
+
runs: docs.map((r) => ({
|
|
3357
|
+
_id: r._id,
|
|
3358
|
+
conversation_id: r.conversation_id,
|
|
3359
|
+
engine: r.engine,
|
|
3360
|
+
state: r.state,
|
|
3361
|
+
date_created_ts: r.date_created_ts,
|
|
3362
|
+
started_ts: r.started_ts,
|
|
3363
|
+
ended_ts: r.ended_ts,
|
|
3364
|
+
error: r.error,
|
|
3365
|
+
file_count: (r.files || []).length,
|
|
3366
|
+
command_count: (r.commands || []).length,
|
|
3367
|
+
usage: r.usage,
|
|
3368
|
+
})),
|
|
3369
|
+
},
|
|
3370
|
+
};
|
|
3371
|
+
} catch (err) {
|
|
3372
|
+
console.error(`[code_run] list failed: ${err?.message || err}`);
|
|
3373
|
+
return { code: -1, data: 'could not read your code runs' };
|
|
3374
|
+
}
|
|
3375
|
+
};
|
|
3376
|
+
|
|
3377
|
+
// One run in full: what it ran, what it touched, and what it cannot see. `watched` matters as much
|
|
3378
|
+
// as `files`: a panel that shows an empty list has to be able to say whether nothing changed or
|
|
3379
|
+
// nothing was being watched.
|
|
3380
|
+
export const get_code_run = async function (req) {
|
|
3381
|
+
const { uid, profile_id, run_id } = req;
|
|
3382
|
+
try {
|
|
3383
|
+
const { run, error } = await load_own_code_run(uid, profile_id, run_id);
|
|
3384
|
+
if (error) return error;
|
|
3385
|
+
return {
|
|
3386
|
+
code: 1,
|
|
3387
|
+
data: {
|
|
3388
|
+
_id: run._id,
|
|
3389
|
+
conversation_id: run.conversation_id,
|
|
3390
|
+
engine: run.engine,
|
|
3391
|
+
state: run.state,
|
|
3392
|
+
error: run.error,
|
|
3393
|
+
started_ts: run.started_ts,
|
|
3394
|
+
ended_ts: run.ended_ts,
|
|
3395
|
+
usage: run.usage,
|
|
3396
|
+
resumed_session_id: run.resumed_session_id,
|
|
3397
|
+
// UI-226: present only on a repository-backed run, and what the panel keys its git header
|
|
3398
|
+
// and its Commit / Push / Open PR actions off.
|
|
3399
|
+
repo_id: run.repo_id || null,
|
|
3400
|
+
branch: run.branch || null,
|
|
3401
|
+
base_sha: run.base_sha || null,
|
|
3402
|
+
commands: (run.commands || []).map((c) => ({ command: c.command, exit_code: c.exit_code, ts: c.ts, output: c.output })),
|
|
3403
|
+
files: (run.files || []).map((f) => ({ path: f.path, rel: f.rel, change: f.change, diff_available: !!f.diff_available, revert_available: !!f.revert_available, reverted_ts: f.reverted_ts || null })),
|
|
3404
|
+
files_truncated: !!run.files_truncated,
|
|
3405
|
+
watched: run.snapshot?.watched || [],
|
|
3406
|
+
skipped_roots: (run.snapshot?.roots || []).filter((r) => r.kind === 'skipped').map((r) => ({ root: r.root, reason: r.reason })),
|
|
3407
|
+
},
|
|
3408
|
+
};
|
|
3409
|
+
} catch (err) {
|
|
3410
|
+
console.error(`[code_run] read failed: ${err?.message || err}`);
|
|
3411
|
+
return { code: -1, data: 'could not read that code run' };
|
|
3412
|
+
}
|
|
3413
|
+
};
|
|
3414
|
+
|
|
3415
|
+
// Stop a run from the workspace. The chat's own Stop goes through abort_job (the run watches that
|
|
3416
|
+
// flag); this is the same thing addressed by run instead, for a surface that is looking at the run
|
|
3417
|
+
// rather than at the turn that started it.
|
|
3418
|
+
export const abort_code_run = async function (req) {
|
|
3419
|
+
const { uid, profile_id, run_id } = req;
|
|
3420
|
+
try {
|
|
3421
|
+
const { run, error } = await load_own_code_run(uid, profile_id, run_id);
|
|
3422
|
+
if (error) return error;
|
|
3423
|
+
if (run.state !== 'running' && run.state !== 'queued') return { code: 2, data: { run_id, state: run.state, already_finished: true } };
|
|
3424
|
+
|
|
3425
|
+
const live = _code_run_tails.get(run_id);
|
|
3426
|
+
kill_code_run_process(run.pid);
|
|
3427
|
+
if (live) live();
|
|
3428
|
+
// The in-memory run object is the one the tail owns; this may be a different process, so
|
|
3429
|
+
// finalize from the doc we just read.
|
|
3430
|
+
await finalize_code_run(run, { aborted: true });
|
|
3431
|
+
return { code: 1, data: { run_id, state: run.state } };
|
|
3432
|
+
} catch (err) {
|
|
3433
|
+
console.error(`[code_run] abort failed: ${err?.message || err}`);
|
|
3434
|
+
return { code: -1, data: 'could not stop that run' };
|
|
3435
|
+
}
|
|
3436
|
+
};
|
|
3437
|
+
|
|
3438
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
3439
|
+
// Code chats against a real repository (UI-226)
|
|
3440
|
+
//
|
|
3441
|
+
// Boaz: "the new code tab that you added should work with git", meaning the customer's own GitHub or
|
|
3442
|
+
// GitLab repository, and "the git thing is only for the code tab, vps and etc stay the same". On what
|
|
3443
|
+
// happens after a run: "it should work like claude code".
|
|
3444
|
+
//
|
|
3445
|
+
// So a Code-tab chat on a project with a connected repo behaves like Claude Code on a checkout: the
|
|
3446
|
+
// run edits a real WORKING COPY on this server, nothing is committed or pushed behind the user's
|
|
3447
|
+
// back, the file list is the uncommitted change set against HEAD, and Commit / Push / Open PR are
|
|
3448
|
+
// things the user does. The repository is the record, so these runs need no hidden snapshot.
|
|
3449
|
+
//
|
|
3450
|
+
// The VPS path is untouched by all of this: a chat opened from a project's AI panel still runs Codex
|
|
3451
|
+
// over SSH on that machine with the snapshot-based file list.
|
|
3452
|
+
//
|
|
3453
|
+
// A repo lives on its PROJECT, and its token lives on a `git_repo` doc in the ACCOUNT'S project
|
|
3454
|
+
// database (the one that already holds chat_conversation and code_run). Deliberately not on the app
|
|
3455
|
+
// doc in xuda_master: control DBs replicate bidirectionally fleet-wide through the master hub, so a
|
|
3456
|
+
// token there would be copied to every region. No read path ever returns it.
|
|
3457
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
3458
|
+
|
|
3459
|
+
const GIT_PROVIDERS = ['github', 'gitlab', 'file'];
|
|
3460
|
+
const GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
3461
|
+
const GIT_CMD_TIMEOUT_MS = 2 * 60 * 1000;
|
|
3462
|
+
// A repo bigger than this is reported rather than half-cloned onto the region server.
|
|
3463
|
+
const GIT_CLONE_MAX_MB = _conf.code_runs?.clone_max_mb || 2048;
|
|
3464
|
+
|
|
3465
|
+
const git_repos_root = function () {
|
|
3466
|
+
return _conf.repos_drive_path || path.join(process.env.XUDA_HOME, 'code_repos');
|
|
3467
|
+
};
|
|
3468
|
+
|
|
3469
|
+
const git_work_dir = function (uid, repo_id) {
|
|
3470
|
+
return path.join(git_repos_root(), uid, repo_id);
|
|
3471
|
+
};
|
|
3472
|
+
|
|
3473
|
+
// What the remote is, from its own URL. `file` exists for a local bare repo, which is how the git
|
|
3474
|
+
// mechanics are tested without anybody's credentials.
|
|
3475
|
+
const git_detect_provider = function (remote_url) {
|
|
3476
|
+
const url = String(remote_url || '');
|
|
3477
|
+
if (url.startsWith('file://') || url.startsWith('/')) return 'file';
|
|
3478
|
+
if (/github\.com/i.test(url)) return 'github';
|
|
3479
|
+
if (/gitlab/i.test(url)) return 'gitlab';
|
|
3480
|
+
return null;
|
|
3481
|
+
};
|
|
3482
|
+
|
|
3483
|
+
// The username half of an HTTPS token auth, which differs per provider and is NOT the secret. It is
|
|
3484
|
+
// stored in the URL; the token itself is only ever handed over through askpass (below).
|
|
3485
|
+
const git_token_user = function (provider) {
|
|
3486
|
+
return provider === 'gitlab' ? 'oauth2' : 'x-access-token';
|
|
3487
|
+
};
|
|
3488
|
+
|
|
3489
|
+
const git_normalize_remote = function (remote_url, provider) {
|
|
3490
|
+
const url = String(remote_url || '').trim();
|
|
3491
|
+
if (provider === 'file') return url;
|
|
3492
|
+
try {
|
|
3493
|
+
const parsed = new URL(url);
|
|
3494
|
+
// Strip anything already in the URL's user info: a token pasted into the url would otherwise be
|
|
3495
|
+
// stored in the doc twice and end up in .git/config.
|
|
3496
|
+
parsed.username = git_token_user(provider);
|
|
3497
|
+
parsed.password = '';
|
|
3498
|
+
return parsed.toString();
|
|
3499
|
+
} catch (err) {
|
|
3500
|
+
return url;
|
|
3501
|
+
}
|
|
3502
|
+
};
|
|
3503
|
+
|
|
3504
|
+
// ── Running git without the token ever touching argv, .git/config or a log ──────────────────
|
|
3505
|
+
// The token goes in via GIT_ASKPASS: git asks for the password, the helper prints what is in the
|
|
3506
|
+
// environment. That keeps it out of the process list (which any tenant on the box can read), out of
|
|
3507
|
+
// the repository config, and out of the run log the panel shows the customer. GIT_TERMINAL_PROMPT=0
|
|
3508
|
+
// so a bad token fails immediately instead of hanging on a prompt.
|
|
3509
|
+
const GIT_ASKPASS_PATH = () => path.join(git_repos_root(), '.xuda_askpass.sh');
|
|
3510
|
+
|
|
3511
|
+
const ensure_git_askpass = async function () {
|
|
3512
|
+
const p = GIT_ASKPASS_PATH();
|
|
3513
|
+
try {
|
|
3514
|
+
await fs.promises.mkdir(path.dirname(p), { recursive: true });
|
|
3515
|
+
await fs.promises.writeFile(p, '#!/bin/sh\nprintf "%s" "$XUDA_GIT_TOKEN"\n', { mode: 0o700 });
|
|
3516
|
+
} catch (err) {
|
|
3517
|
+
console.error(`[git] askpass write failed: ${err?.message || err}`);
|
|
3518
|
+
}
|
|
3519
|
+
return p;
|
|
3520
|
+
};
|
|
3521
|
+
|
|
3522
|
+
const git_exec = async function ({ cwd, args, token, timeout_ms = GIT_CMD_TIMEOUT_MS }) {
|
|
3523
|
+
const askpass = await ensure_git_askpass();
|
|
3524
|
+
const env = {
|
|
3525
|
+
...process.env,
|
|
3526
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
3527
|
+
GIT_ASKPASS: askpass,
|
|
3528
|
+
// A run's commits are Xuda's, not a person's, and a box with no git identity would otherwise
|
|
3529
|
+
// refuse to commit at all.
|
|
3530
|
+
GIT_AUTHOR_NAME: 'Xuda',
|
|
3531
|
+
GIT_AUTHOR_EMAIL: 'code@xuda.ai',
|
|
3532
|
+
GIT_COMMITTER_NAME: 'Xuda',
|
|
3533
|
+
GIT_COMMITTER_EMAIL: 'code@xuda.ai',
|
|
3534
|
+
...(token ? { XUDA_GIT_TOKEN: token } : {}),
|
|
3535
|
+
};
|
|
3536
|
+
const ret = await run_process('git', args, null, { cwd, env, timeout: timeout_ms, killSignal: 'SIGKILL' });
|
|
3537
|
+
return { exit_code: ret.exit_code, stdout: (ret.stdout || '').trim(), stderr: (ret.stderr || '').trim() };
|
|
3538
|
+
};
|
|
3539
|
+
|
|
3540
|
+
// ── The git_repo doc ───────────────────────────────────────────────────────────────────────
|
|
3541
|
+
const git_repo_safe = function (doc) {
|
|
3542
|
+
if (!doc) return null;
|
|
3543
|
+
return {
|
|
3544
|
+
_id: doc._id,
|
|
3545
|
+
app_id: doc.app_id,
|
|
3546
|
+
provider: doc.provider,
|
|
3547
|
+
remote_url: doc.remote_url,
|
|
3548
|
+
default_branch: doc.default_branch,
|
|
3549
|
+
token_last4: doc.token_last4 || null,
|
|
3550
|
+
connected_by_uid: doc.connected_by_uid,
|
|
3551
|
+
stat: doc.stat,
|
|
3552
|
+
ts: doc.ts,
|
|
3553
|
+
};
|
|
3554
|
+
};
|
|
3555
|
+
|
|
3556
|
+
const load_git_repo = async function (uid, profile_id, repo_id) {
|
|
3557
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3558
|
+
try {
|
|
3559
|
+
const doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, repo_id);
|
|
3560
|
+
if (!doc || doc.docType !== 'git_repo' || doc.stat === 4) return { error: { code: -1, data: 'repository not found' } };
|
|
3561
|
+
return { repo: doc, app_id: account_profile_info.app_id };
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
return { error: { code: -1, data: 'repository not found' } };
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
|
|
3567
|
+
// Connect a repository to a project. The token is checked against the real remote BEFORE anything is
|
|
3568
|
+
// stored, so a typo is an error message rather than a repo that fails on first use.
|
|
3569
|
+
export const git_repo_connect = async function (req) {
|
|
3570
|
+
const { uid, profile_id, app_id, remote_url, token, default_branch } = req;
|
|
3571
|
+
try {
|
|
3572
|
+
if (!app_id) return { code: -1, data: 'app_id is required' };
|
|
3573
|
+
if (!remote_url) return { code: -1, data: 'a repository url is required' };
|
|
3574
|
+
const provider = req.provider && GIT_PROVIDERS.includes(req.provider) ? req.provider : git_detect_provider(remote_url);
|
|
3575
|
+
if (!provider) return { code: -1, data: 'only GitHub and GitLab repositories are supported' };
|
|
3576
|
+
if (provider !== 'file' && !token) return { code: -1, data: 'an access token is required' };
|
|
3577
|
+
|
|
3578
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3579
|
+
const normalized = git_normalize_remote(remote_url, provider);
|
|
3580
|
+
|
|
3581
|
+
// The reachability test IS the validation: ls-remote authenticates, resolves the default branch
|
|
3582
|
+
// and proves the token has read access, in one round trip.
|
|
3583
|
+
const ls = await git_exec({ args: ['ls-remote', '--symref', normalized, 'HEAD'], token, timeout_ms: 60000 });
|
|
3584
|
+
if (ls.exit_code !== 0) {
|
|
3585
|
+
// Never echo git's stderr: it can carry the URL with credentials on some transports.
|
|
3586
|
+
console.error(`[git] connect ls-remote failed for ${app_id}: exit ${ls.exit_code}`);
|
|
3587
|
+
return { code: -1, data: 'Xuda could not reach that repository with those details. Check the url and the token, and that the token can read this repository.' };
|
|
3588
|
+
}
|
|
3589
|
+
const head_ref = (ls.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m) || [])[1];
|
|
3590
|
+
const resolved_branch = default_branch || head_ref || 'main';
|
|
3591
|
+
|
|
3592
|
+
// One repo per project for now: reconnecting replaces what was there rather than stacking.
|
|
3593
|
+
const existing = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'git_repo', app_id, stat: 3 }, limit: 5 });
|
|
3594
|
+
for (const doc of existing?.docs || []) {
|
|
3595
|
+
doc.stat = 4;
|
|
3596
|
+
doc.ts = Date.now();
|
|
3597
|
+
await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
|
|
3598
|
+
}
|
|
2059
3599
|
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
:
|
|
3600
|
+
const repo_doc = {
|
|
3601
|
+
_id: await _common.xuda_get_uuid('git_repo'),
|
|
3602
|
+
docType: 'git_repo',
|
|
3603
|
+
stat: 3,
|
|
3604
|
+
app_id,
|
|
3605
|
+
provider,
|
|
3606
|
+
remote_url: normalized,
|
|
3607
|
+
default_branch: resolved_branch,
|
|
3608
|
+
token: token || null,
|
|
3609
|
+
token_last4: token ? String(token).slice(-4) : null,
|
|
3610
|
+
connected_by_uid: uid,
|
|
3611
|
+
date_created_ts: Date.now(),
|
|
3612
|
+
ts: Date.now(),
|
|
3613
|
+
};
|
|
3614
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, repo_doc);
|
|
3615
|
+
return { code: 1, data: { repo: git_repo_safe(repo_doc) } };
|
|
3616
|
+
} catch (err) {
|
|
3617
|
+
console.error(`[git] connect failed: ${err?.message || err}`);
|
|
3618
|
+
return { code: -1, data: 'could not connect that repository' };
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
2063
3621
|
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
3622
|
+
export const git_repo_list = async function (req) {
|
|
3623
|
+
const { uid, profile_id, app_id } = req;
|
|
3624
|
+
try {
|
|
3625
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3626
|
+
const selector = { docType: 'git_repo', stat: 3 };
|
|
3627
|
+
if (app_id) selector.app_id = app_id;
|
|
3628
|
+
const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector, limit: 50 });
|
|
3629
|
+
return { code: 1, data: { repos: (q?.docs || []).map(git_repo_safe) } };
|
|
3630
|
+
} catch (err) {
|
|
3631
|
+
console.error(`[git] list failed: ${err?.message || err}`);
|
|
3632
|
+
return { code: -1, data: 'could not read your repositories' };
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
2067
3635
|
|
|
2068
|
-
|
|
2069
|
-
|
|
3636
|
+
export const git_repo_disconnect = async function (req) {
|
|
3637
|
+
const { uid, profile_id, repo_id } = req;
|
|
3638
|
+
try {
|
|
3639
|
+
const { repo, app_id, error } = await load_git_repo(uid, profile_id, repo_id);
|
|
3640
|
+
if (error) return error;
|
|
3641
|
+
repo.stat = 4;
|
|
3642
|
+
repo.token = null;
|
|
3643
|
+
repo.ts = Date.now();
|
|
3644
|
+
await db_module.save_app_couch_doc(app_id, repo);
|
|
3645
|
+
// The working copy goes with it: it holds the repository's code and there is nothing left that
|
|
3646
|
+
// may read it.
|
|
3647
|
+
await fs.promises.rm(git_work_dir(uid, repo_id), { recursive: true, force: true }).catch(() => {});
|
|
3648
|
+
return { code: 1, data: { repo_id, disconnected: true } };
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
console.error(`[git] disconnect failed: ${err?.message || err}`);
|
|
3651
|
+
return { code: -1, data: 'could not disconnect that repository' };
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
2070
3654
|
|
|
2071
|
-
|
|
2072
|
-
|
|
3655
|
+
// ── The working copy ───────────────────────────────────────────────────────────────────────
|
|
3656
|
+
// One per repo, reused across runs and chats. Cloned on first use, fetched after that. A chat owns a
|
|
3657
|
+
// BRANCH off the default branch; the default branch is never checked out for writing, so nothing a
|
|
3658
|
+
// run does can land on main without a person merging it.
|
|
3659
|
+
const git_chat_branch = function (conversation_id) {
|
|
3660
|
+
return `xuda/${String(conversation_id || '').replace(/^cov_/, '').slice(0, 12)}`;
|
|
3661
|
+
};
|
|
2073
3662
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
3663
|
+
const ensure_git_working_copy = async function ({ uid, repo, conversation_id }) {
|
|
3664
|
+
const dir = git_work_dir(uid, repo._id);
|
|
3665
|
+
const token = repo.token || null;
|
|
3666
|
+
const branch = git_chat_branch(conversation_id);
|
|
3667
|
+
await fs.promises.mkdir(path.dirname(dir), { recursive: true });
|
|
2077
3668
|
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
3669
|
+
const has_repo = await fs.promises
|
|
3670
|
+
.stat(path.join(dir, '.git'))
|
|
3671
|
+
.then(() => true)
|
|
3672
|
+
.catch(() => false);
|
|
2081
3673
|
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
codex_args = ['-c', `mcp_servers.xuda.url="${agent_mcp.url}"`, '-c', `mcp_servers.xuda.bearer_token_env_var="${agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY'}"`, ...codex_args];
|
|
3674
|
+
if (!has_repo) {
|
|
3675
|
+
// Size first: a repo too large to sit on the region server is refused with a reason instead of
|
|
3676
|
+
// filling the disk halfway through.
|
|
3677
|
+
if (repo.provider !== 'file') {
|
|
3678
|
+
const info = await git_exec({ args: ['ls-remote', '--heads', repo.remote_url], token, timeout_ms: 60000 });
|
|
3679
|
+
if (info.exit_code !== 0) return { error: 'Xuda could not reach that repository. Check the token is still valid.' };
|
|
2089
3680
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
3681
|
+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3682
|
+
const clone = await git_exec({ args: ['clone', '--no-tags', repo.remote_url, dir], token, timeout_ms: GIT_CLONE_TIMEOUT_MS });
|
|
3683
|
+
if (clone.exit_code !== 0) {
|
|
3684
|
+
console.error(`[git] clone failed for ${repo._id}: exit ${clone.exit_code}`);
|
|
3685
|
+
return { error: 'Xuda could not clone that repository.' };
|
|
2092
3686
|
}
|
|
2093
|
-
|
|
3687
|
+
const size_mb = Number(((await run_process('bash', ['-lc', `du -sm ${JSON.stringify(dir)} | cut -f1`], null, { timeout: 60000 })).stdout || '').trim()) || 0;
|
|
3688
|
+
if (size_mb > GIT_CLONE_MAX_MB) {
|
|
3689
|
+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3690
|
+
return { error: `That repository is ${size_mb} MB, which is over the ${GIT_CLONE_MAX_MB} MB limit for a working copy.` };
|
|
3691
|
+
}
|
|
3692
|
+
} else {
|
|
3693
|
+
const fetched = await git_exec({ cwd: dir, args: ['fetch', '--prune', 'origin'], token });
|
|
3694
|
+
if (fetched.exit_code !== 0) console.warn(`[git] fetch failed for ${repo._id}: exit ${fetched.exit_code}`);
|
|
3695
|
+
}
|
|
2094
3696
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
3697
|
+
// The chat's branch: reuse it when it exists (locally or on the remote), otherwise cut it from the
|
|
3698
|
+
// default branch. Existing local work is never thrown away, which is why this is not a hard reset.
|
|
3699
|
+
const local = await git_exec({ cwd: dir, args: ['rev-parse', '--verify', `refs/heads/${branch}`], token });
|
|
3700
|
+
if (local.exit_code === 0) {
|
|
3701
|
+
const co = await git_exec({ cwd: dir, args: ['checkout', branch], token });
|
|
3702
|
+
if (co.exit_code !== 0) return { error: 'Xuda could not switch to this chat’s branch. There may be uncommitted work in the way.' };
|
|
3703
|
+
} else {
|
|
3704
|
+
const remote = await git_exec({ cwd: dir, args: ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], token });
|
|
3705
|
+
const from = remote.exit_code === 0 ? `origin/${branch}` : `origin/${repo.default_branch}`;
|
|
3706
|
+
const created = await git_exec({ cwd: dir, args: ['checkout', '-B', branch, from], token });
|
|
3707
|
+
if (created.exit_code !== 0) {
|
|
3708
|
+
// A brand new repository has no origin/<default> yet, so fall back to whatever HEAD is.
|
|
3709
|
+
const fallback = await git_exec({ cwd: dir, args: ['checkout', '-B', branch], token });
|
|
3710
|
+
if (fallback.exit_code !== 0) return { error: 'Xuda could not create a branch for this chat.' };
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
const head = await git_exec({ cwd: dir, args: ['rev-parse', 'HEAD'], token });
|
|
3715
|
+
// Files already modified before the run starts. Recorded so the run is never credited with a
|
|
3716
|
+
// change somebody else made by hand.
|
|
3717
|
+
// --untracked-files=all so the before list is measured the same way the after list is; otherwise a
|
|
3718
|
+
// file the user had already dropped in would read as one the run created.
|
|
3719
|
+
const dirty = await git_exec({ cwd: dir, args: ['status', '--porcelain', '--untracked-files=all'], token });
|
|
3720
|
+
return {
|
|
3721
|
+
dir,
|
|
3722
|
+
branch,
|
|
3723
|
+
base_sha: head.exit_code === 0 ? head.stdout : null,
|
|
3724
|
+
pre_dirty: (dirty.stdout || '')
|
|
3725
|
+
.split('\n')
|
|
3726
|
+
.map((l) => git_parse_status_line(l)?.rel)
|
|
3727
|
+
.filter(Boolean),
|
|
3728
|
+
};
|
|
3729
|
+
};
|
|
3730
|
+
|
|
3731
|
+
// ── Status, commit, push, pull request ─────────────────────────────────────────────────────
|
|
3732
|
+
export const git_repo_status = async function (req) {
|
|
3733
|
+
const { uid, profile_id, repo_id, conversation_id } = req;
|
|
3734
|
+
try {
|
|
3735
|
+
const { repo, error } = await load_git_repo(uid, profile_id, repo_id);
|
|
3736
|
+
if (error) return error;
|
|
3737
|
+
const dir = git_work_dir(uid, repo_id);
|
|
3738
|
+
const exists = await fs.promises
|
|
3739
|
+
.stat(path.join(dir, '.git'))
|
|
3740
|
+
.then(() => true)
|
|
3741
|
+
.catch(() => false);
|
|
3742
|
+
if (!exists) return { code: 1, data: { repo: git_repo_safe(repo), cloned: false, branch: conversation_id ? git_chat_branch(conversation_id) : null } };
|
|
3743
|
+
|
|
3744
|
+
const token = repo.token || null;
|
|
3745
|
+
const branch = (await git_exec({ cwd: dir, args: ['rev-parse', '--abbrev-ref', 'HEAD'], token })).stdout;
|
|
3746
|
+
const status = await git_exec({ cwd: dir, args: ['status', '--porcelain'], token });
|
|
3747
|
+
const last = await git_exec({ cwd: dir, args: ['log', '-1', '--pretty=%h|%s|%ct'], token });
|
|
3748
|
+
// Ahead of WHAT: the branch's upstream when it has one, otherwise the default branch it was cut
|
|
3749
|
+
// from. Measuring only against `origin/<branch>` reported 0 ahead for a branch that has never
|
|
3750
|
+
// been pushed, which is the exact moment Push matters, and the button disabled itself.
|
|
3751
|
+
const upstream = await git_exec({ cwd: dir, args: ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], token });
|
|
3752
|
+
const has_upstream = upstream.exit_code === 0;
|
|
3753
|
+
const compare_to = has_upstream ? `origin/${branch}` : `origin/${repo.default_branch}`;
|
|
3754
|
+
const counts = await git_exec({ cwd: dir, args: ['rev-list', '--left-right', '--count', `${compare_to}...${branch}`], token });
|
|
3755
|
+
const [behind, ahead] = counts.exit_code === 0 ? counts.stdout.split(/\s+/).map((n) => Number(n) || 0) : [0, 0];
|
|
3756
|
+
const [sha, subject, ts] = (last.stdout || '').split('|');
|
|
3757
|
+
return {
|
|
3758
|
+
code: 1,
|
|
3759
|
+
data: {
|
|
3760
|
+
repo: git_repo_safe(repo),
|
|
3761
|
+
cloned: true,
|
|
3762
|
+
branch,
|
|
3763
|
+
ahead,
|
|
3764
|
+
behind,
|
|
3765
|
+
has_upstream,
|
|
3766
|
+
uncommitted: (status.stdout || '').split('\n').filter(Boolean).length,
|
|
3767
|
+
last_commit: sha ? { sha, subject, ts: Number(ts) * 1000 } : null,
|
|
2100
3768
|
},
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
const completed_event_with_usage = events.findLast((event) => event?.type === 'turn.completed' && event.usage);
|
|
2108
|
-
codex_usage = normalize_codex_usage(completed_event_with_usage?.usage);
|
|
2109
|
-
}
|
|
3769
|
+
};
|
|
3770
|
+
} catch (err) {
|
|
3771
|
+
console.error(`[git] status failed: ${err?.message || err}`);
|
|
3772
|
+
return { code: -1, data: 'could not read the repository status' };
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
2110
3775
|
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
3776
|
+
export const git_commit = async function (req) {
|
|
3777
|
+
const { uid, profile_id, repo_id, message, paths } = req;
|
|
3778
|
+
try {
|
|
3779
|
+
const { repo, error } = await load_git_repo(uid, profile_id, repo_id);
|
|
3780
|
+
if (error) return error;
|
|
3781
|
+
const dir = git_work_dir(uid, repo_id);
|
|
3782
|
+
const token = repo.token || null;
|
|
3783
|
+
|
|
3784
|
+
const add_args = Array.isArray(paths) && paths.length ? ['add', '--', ...paths] : ['add', '-A'];
|
|
3785
|
+
const added = await git_exec({ cwd: dir, args: add_args, token });
|
|
3786
|
+
if (added.exit_code !== 0) return { code: -1, data: 'could not stage those changes' };
|
|
3787
|
+
|
|
3788
|
+
const staged = await git_exec({ cwd: dir, args: ['diff', '--cached', '--name-only'], token });
|
|
3789
|
+
if (!staged.stdout) return { code: 2, data: { nothing_to_commit: true } };
|
|
3790
|
+
|
|
3791
|
+
const commit = await git_exec({ cwd: dir, args: ['commit', '-m', String(message || 'Changes from a Xuda code chat').slice(0, 500)], token });
|
|
3792
|
+
if (commit.exit_code !== 0) {
|
|
3793
|
+
console.error(`[git] commit failed for ${repo_id}: exit ${commit.exit_code}`);
|
|
3794
|
+
return { code: -1, data: 'could not commit those changes' };
|
|
2128
3795
|
}
|
|
3796
|
+
const head = await git_exec({ cwd: dir, args: ['rev-parse', 'HEAD'], token });
|
|
3797
|
+
return { code: 1, data: { sha: head.stdout, files: staged.stdout.split('\n').filter(Boolean) } };
|
|
3798
|
+
} catch (err) {
|
|
3799
|
+
console.error(`[git] commit failed: ${err?.message || err}`);
|
|
3800
|
+
return { code: -1, data: 'could not commit those changes' };
|
|
3801
|
+
}
|
|
3802
|
+
};
|
|
2129
3803
|
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
3804
|
+
// Where a pushed branch can be turned into a pull request. Built from the remote url so it works
|
|
3805
|
+
// without any API call, which is also the answer for a provider whose API we do not talk to.
|
|
3806
|
+
const git_compare_url = function (repo, branch) {
|
|
3807
|
+
try {
|
|
3808
|
+
const url = new URL(repo.remote_url);
|
|
3809
|
+
url.username = '';
|
|
3810
|
+
url.password = '';
|
|
3811
|
+
const base = `${url.origin}${url.pathname.replace(/\.git$/, '')}`;
|
|
3812
|
+
if (repo.provider === 'github') return `${base}/compare/${encodeURIComponent(repo.default_branch)}...${encodeURIComponent(branch)}?expand=1`;
|
|
3813
|
+
if (repo.provider === 'gitlab') return `${base}/-/merge_requests/new?merge_request%5Bsource_branch%5D=${encodeURIComponent(branch)}`;
|
|
3814
|
+
return null;
|
|
3815
|
+
} catch (err) {
|
|
3816
|
+
return null;
|
|
3817
|
+
}
|
|
3818
|
+
};
|
|
2134
3819
|
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
};
|
|
3820
|
+
export const git_push = async function (req) {
|
|
3821
|
+
const { uid, profile_id, repo_id } = req;
|
|
3822
|
+
try {
|
|
3823
|
+
const { repo, error } = await load_git_repo(uid, profile_id, repo_id);
|
|
3824
|
+
if (error) return error;
|
|
3825
|
+
const dir = git_work_dir(uid, repo_id);
|
|
3826
|
+
const token = repo.token || null;
|
|
3827
|
+
const branch = req.branch || (await git_exec({ cwd: dir, args: ['rev-parse', '--abbrev-ref', 'HEAD'], token })).stdout;
|
|
3828
|
+
if (!branch || branch === repo.default_branch) {
|
|
3829
|
+
return { code: -1, data: 'Xuda only pushes a chat’s own branch, never the default branch.' };
|
|
3830
|
+
}
|
|
3831
|
+
const pushed = await git_exec({ cwd: dir, args: ['push', '--set-upstream', 'origin', branch], token, timeout_ms: GIT_CLONE_TIMEOUT_MS });
|
|
3832
|
+
if (pushed.exit_code !== 0) {
|
|
3833
|
+
console.error(`[git] push failed for ${repo_id}: exit ${pushed.exit_code}`);
|
|
3834
|
+
return { code: -1, data: 'could not push that branch. The token may not have write access.' };
|
|
3835
|
+
}
|
|
3836
|
+
return { code: 1, data: { branch, compare_url: git_compare_url(repo, branch) } };
|
|
3837
|
+
} catch (err) {
|
|
3838
|
+
console.error(`[git] push failed: ${err?.message || err}`);
|
|
3839
|
+
return { code: -1, data: 'could not push that branch' };
|
|
3840
|
+
}
|
|
3841
|
+
};
|
|
3842
|
+
|
|
3843
|
+
// A real pull request (GitHub) or merge request (GitLab), with the same token. Falls back to the
|
|
3844
|
+
// compare url when the provider has no API we speak, so the user is never left without a way through.
|
|
3845
|
+
export const git_open_pr = async function (req) {
|
|
3846
|
+
const { uid, profile_id, repo_id, title, body } = req;
|
|
3847
|
+
try {
|
|
3848
|
+
const { repo, error } = await load_git_repo(uid, profile_id, repo_id);
|
|
3849
|
+
if (error) return error;
|
|
3850
|
+
const dir = git_work_dir(uid, repo_id);
|
|
3851
|
+
const token = repo.token || null;
|
|
3852
|
+
const branch = req.branch || (await git_exec({ cwd: dir, args: ['rev-parse', '--abbrev-ref', 'HEAD'], token })).stdout;
|
|
3853
|
+
if (!branch) return { code: -1, data: 'this chat has no branch yet' };
|
|
3854
|
+
|
|
3855
|
+
const url = new URL(repo.remote_url);
|
|
3856
|
+
const project_path = url.pathname.replace(/^\/+|\.git$/g, '');
|
|
3857
|
+
|
|
3858
|
+
if (repo.provider === 'github') {
|
|
3859
|
+
const res = await fetch(`https://api.github.com/repos/${project_path}/pulls`, {
|
|
3860
|
+
method: 'POST',
|
|
3861
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'User-Agent': 'xuda' },
|
|
3862
|
+
body: JSON.stringify({ title: String(title || `Changes from a Xuda code chat`).slice(0, 250), head: branch, base: repo.default_branch, body: String(body || '').slice(0, 60000) }),
|
|
3863
|
+
});
|
|
3864
|
+
const json = await res.json().catch(() => ({}));
|
|
3865
|
+
if (!res.ok) {
|
|
3866
|
+
console.error(`[git] github pr failed ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
|
|
3867
|
+
return { code: 2, data: { compare_url: git_compare_url(repo, branch), reason: json?.errors?.[0]?.message || 'the pull request could not be opened automatically' } };
|
|
3868
|
+
}
|
|
3869
|
+
return { code: 1, data: { url: json.html_url, number: json.number } };
|
|
2149
3870
|
}
|
|
2150
3871
|
|
|
2151
|
-
|
|
3872
|
+
if (repo.provider === 'gitlab') {
|
|
3873
|
+
const res = await fetch(`${url.origin}/api/v4/projects/${encodeURIComponent(project_path)}/merge_requests`, {
|
|
3874
|
+
method: 'POST',
|
|
3875
|
+
headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' },
|
|
3876
|
+
body: JSON.stringify({ source_branch: branch, target_branch: repo.default_branch, title: String(title || 'Changes from a Xuda code chat').slice(0, 250), description: String(body || '').slice(0, 60000) }),
|
|
3877
|
+
});
|
|
3878
|
+
const json = await res.json().catch(() => ({}));
|
|
3879
|
+
if (!res.ok) {
|
|
3880
|
+
console.error(`[git] gitlab mr failed ${res.status}`);
|
|
3881
|
+
return { code: 2, data: { compare_url: git_compare_url(repo, branch), reason: 'the merge request could not be opened automatically' } };
|
|
3882
|
+
}
|
|
3883
|
+
return { code: 1, data: { url: json.web_url, number: json.iid } };
|
|
3884
|
+
}
|
|
2152
3885
|
|
|
2153
|
-
return {
|
|
2154
|
-
code: 0,
|
|
2155
|
-
data: {
|
|
2156
|
-
provider: 'openai_codex_cli',
|
|
2157
|
-
remote_host: local_cwd ? null : get_codex_remote_host(ip),
|
|
2158
|
-
command: `${openai_codex_command} ${codex_args.filter((arg) => arg !== '-').join(' ')}`,
|
|
2159
|
-
stdout: ret.stdout,
|
|
2160
|
-
stderr: ret.stderr,
|
|
2161
|
-
events,
|
|
2162
|
-
reasoning,
|
|
2163
|
-
usage: codex_usage,
|
|
2164
|
-
},
|
|
2165
|
-
};
|
|
3886
|
+
return { code: 2, data: { compare_url: git_compare_url(repo, branch), reason: 'this remote has no pull requests' } };
|
|
2166
3887
|
} catch (err) {
|
|
2167
|
-
console.error(err);
|
|
2168
|
-
|
|
2169
|
-
streamText(`Codex request failed: ${err.message}`);
|
|
2170
|
-
emitToDashboard('stream_end');
|
|
2171
|
-
return { code: -1, data: err.message };
|
|
3888
|
+
console.error(`[git] open pr failed: ${err?.message || err}`);
|
|
3889
|
+
return { code: -1, data: 'could not open a pull request' };
|
|
2172
3890
|
}
|
|
2173
3891
|
};
|
|
2174
3892
|
|
|
@@ -2417,8 +4135,48 @@ export const get_chat_conversation = async function (req) {
|
|
|
2417
4135
|
}
|
|
2418
4136
|
};
|
|
2419
4137
|
|
|
4138
|
+
// UI-222: the fields that belong to the RECIPIENT of a shared or purchased item, not to the
|
|
4139
|
+
// owner of the original.
|
|
4140
|
+
//
|
|
4141
|
+
// Six list/info methods swap `doc` for the SOURCE doc when a row is shared or was bought,
|
|
4142
|
+
// because that is where the name, the picture and the instructions live and the recipient has
|
|
4143
|
+
// to see the owner's edits. But `pinned`, `favorite` and `stat` are the recipient's own state
|
|
4144
|
+
// and only ever exist on the recipient's copy, so they have to be carried across the swap.
|
|
4145
|
+
//
|
|
4146
|
+
// Missing one is not cosmetic: the `pinned` and `favorites` filters select on the RECIPIENT's
|
|
4147
|
+
// copy, so a mark that is not carried over produces a row that ANSWERS the filter and then
|
|
4148
|
+
// draws itself unmarked. That is exactly what shared and purchased agents did, they were the
|
|
4149
|
+
// only two of the six sites that named favorite and stat but not pinned, so a pinned shared
|
|
4150
|
+
// agent appeared under the Pinned filter with an unpinned card.
|
|
4151
|
+
//
|
|
4152
|
+
// The two marks live where that kind of doc keeps them: a chat at the top level, a studio doc
|
|
4153
|
+
// (mini app / AI agent) under studio_meta. The shape is read off the recipient's own doc rather
|
|
4154
|
+
// than hardcoded per call site, so one helper serves both.
|
|
4155
|
+
//
|
|
4156
|
+
// `??` rather than a plain assignment, which is what these sites already did: a copy the
|
|
4157
|
+
// recipient has never pinned or favorited carries no mark of its own and keeps showing the
|
|
4158
|
+
// source's.
|
|
4159
|
+
const apply_recipient_marks = function (doc, reference_doc) {
|
|
4160
|
+
if (!doc || !reference_doc) return doc;
|
|
4161
|
+
|
|
4162
|
+
doc.stat = reference_doc.stat ?? doc.stat;
|
|
4163
|
+
|
|
4164
|
+
if (reference_doc.studio_meta) {
|
|
4165
|
+
doc.studio_meta = {
|
|
4166
|
+
...(doc.studio_meta || {}),
|
|
4167
|
+
pinned: reference_doc.studio_meta.pinned ?? doc?.studio_meta?.pinned,
|
|
4168
|
+
favorite: reference_doc.studio_meta.favorite ?? doc?.studio_meta?.favorite,
|
|
4169
|
+
};
|
|
4170
|
+
} else {
|
|
4171
|
+
doc.pinned = reference_doc.pinned ?? doc.pinned;
|
|
4172
|
+
doc.favorite = reference_doc.favorite ?? doc.favorite;
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
return doc;
|
|
4176
|
+
};
|
|
4177
|
+
|
|
2420
4178
|
export const get_ai_chats = async function (req, job_id, headers) {
|
|
2421
|
-
let { uid, reference_type, reference_id = '', _id, search, filter_type = 'all', conversation_type, limit, skip, conversation_id, profile_id } = req;
|
|
4179
|
+
let { uid, reference_type, reference_id = '', _id, search, filter_type = 'all', conversation_type, limit, skip, conversation_id, profile_id, chat_class } = req;
|
|
2422
4180
|
|
|
2423
4181
|
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
2424
4182
|
|
|
@@ -2495,6 +4253,11 @@ export const get_ai_chats = async function (req, job_id, headers) {
|
|
|
2495
4253
|
};
|
|
2496
4254
|
|
|
2497
4255
|
const get_data_find = async function () {
|
|
4256
|
+
// UI-223: the code list is an equality filter, so this account's pre-`chat_class` chats have
|
|
4257
|
+
// to be stamped before it can find them. One bounded pass per account, cached after that.
|
|
4258
|
+
if (chat_class === 'code' && !reference_id && !conversation_id) {
|
|
4259
|
+
await ensure_chat_class_backfilled(uid, account_profile_info.app_id);
|
|
4260
|
+
}
|
|
2498
4261
|
let opt = {
|
|
2499
4262
|
selector: {
|
|
2500
4263
|
docType: 'chat_conversation',
|
|
@@ -2544,6 +4307,12 @@ export const get_ai_chats = async function (req, job_id, headers) {
|
|
|
2544
4307
|
break;
|
|
2545
4308
|
}
|
|
2546
4309
|
|
|
4310
|
+
// UI-221
|
|
4311
|
+
case 'favorites': {
|
|
4312
|
+
opt.selector.favorite = true;
|
|
4313
|
+
break;
|
|
4314
|
+
}
|
|
4315
|
+
|
|
2547
4316
|
case 'mine': {
|
|
2548
4317
|
opt.selector.uid = uid;
|
|
2549
4318
|
|
|
@@ -2559,6 +4328,16 @@ export const get_ai_chats = async function (req, job_id, headers) {
|
|
|
2559
4328
|
opt.selector.conversation_type = conversation_type;
|
|
2560
4329
|
}
|
|
2561
4330
|
|
|
4331
|
+
// UI-223: the two chat worlds are listed separately. `chat_class: 'code'` asks for the code
|
|
4332
|
+
// chats; anything else means the regular list, which EXCLUDES them. The exclusion is $ne so
|
|
4333
|
+
// it also matches every chat created before the field existed (mango $ne matches a missing
|
|
4334
|
+
// field), which is what keeps old chats visible without a backfill being on the critical
|
|
4335
|
+
// path. Skipped when reading the items INSIDE one conversation, where the class is not a
|
|
4336
|
+
// filter but a property of the thread already identified.
|
|
4337
|
+
if (!reference_id && !conversation_id) {
|
|
4338
|
+
opt.selector.chat_class = chat_class === 'code' ? 'code' : { $ne: 'code' };
|
|
4339
|
+
}
|
|
4340
|
+
|
|
2562
4341
|
if (reference_id) {
|
|
2563
4342
|
opt.selector.reference_id = reference_id;
|
|
2564
4343
|
}
|
|
@@ -2755,8 +4534,9 @@ export const get_ai_chats = async function (req, job_id, headers) {
|
|
|
2755
4534
|
reference_doc = _.cloneDeep(doc);
|
|
2756
4535
|
doc = await db_module.get_app_couch_doc_native(reference_doc.shared_from_app_id, reference_doc.share_item_id);
|
|
2757
4536
|
doc.reference_doc = reference_doc;
|
|
2758
|
-
|
|
2759
|
-
|
|
4537
|
+
// UI-221 / UI-222: pinned and favorite are the RECIPIENT's marks, so a chat shared
|
|
4538
|
+
// with you can be pinned or favorited without touching the owner's copy.
|
|
4539
|
+
apply_recipient_marks(doc, reference_doc);
|
|
2760
4540
|
}
|
|
2761
4541
|
|
|
2762
4542
|
doc.interactions = contact_chat_conversation_count_ret[doc._id];
|
|
@@ -3528,6 +5308,12 @@ export const get_ai_agents = async function (req, job_id, headers) {
|
|
|
3528
5308
|
break;
|
|
3529
5309
|
}
|
|
3530
5310
|
|
|
5311
|
+
// UI-221
|
|
5312
|
+
case 'favorites': {
|
|
5313
|
+
opt.selector['studio_meta.favorite'] = true;
|
|
5314
|
+
break;
|
|
5315
|
+
}
|
|
5316
|
+
|
|
3531
5317
|
case 'mine': {
|
|
3532
5318
|
opt.selector['studio_meta.createdByUid'] = uid;
|
|
3533
5319
|
|
|
@@ -3630,7 +5416,12 @@ export const get_ai_agents = async function (req, job_id, headers) {
|
|
|
3630
5416
|
}
|
|
3631
5417
|
|
|
3632
5418
|
doc.reference_doc = reference_doc;
|
|
3633
|
-
doc
|
|
5419
|
+
// UI-221 / UI-222: the shared card renders the SOURCE agent doc, but pinned and
|
|
5420
|
+
// favorite are the recipient's own marks and both filters read the recipient's copy,
|
|
5421
|
+
// so they have to be carried over. Agents were the last two of the six carry-over
|
|
5422
|
+
// sites still missing `pinned`, which is why a pinned shared agent used to answer the
|
|
5423
|
+
// Pinned filter and then draw an unpinned card.
|
|
5424
|
+
apply_recipient_marks(doc, reference_doc);
|
|
3634
5425
|
}
|
|
3635
5426
|
|
|
3636
5427
|
// purchased
|
|
@@ -3638,7 +5429,8 @@ export const get_ai_agents = async function (req, job_id, headers) {
|
|
|
3638
5429
|
reference_doc = _.cloneDeep(doc);
|
|
3639
5430
|
doc = await db_module.get_app_couch_doc_native(reference_doc.studio_meta.installed_from_app_id, doc._id);
|
|
3640
5431
|
doc.reference_doc = reference_doc;
|
|
3641
|
-
|
|
5432
|
+
// UI-221 / UI-222
|
|
5433
|
+
apply_recipient_marks(doc, reference_doc);
|
|
3642
5434
|
}
|
|
3643
5435
|
|
|
3644
5436
|
let info_doc = await get_ai_agent_info(uid, job_id, headers, doc);
|
|
@@ -4135,6 +5927,12 @@ export const get_apps = async function (req, job_id, headers) {
|
|
|
4135
5927
|
break;
|
|
4136
5928
|
}
|
|
4137
5929
|
|
|
5930
|
+
// UI-221
|
|
5931
|
+
case 'favorites': {
|
|
5932
|
+
opt.selector['studio_meta.favorite'] = true;
|
|
5933
|
+
break;
|
|
5934
|
+
}
|
|
5935
|
+
|
|
4138
5936
|
default:
|
|
4139
5937
|
break;
|
|
4140
5938
|
}
|
|
@@ -4228,11 +6026,8 @@ export const get_apps = async function (req, job_id, headers) {
|
|
|
4228
6026
|
reference_doc = _.cloneDeep(doc);
|
|
4229
6027
|
doc = await db_module.get_app_couch_doc_native(reference_doc.studio_meta.shared_from_app_id, reference_doc.studio_meta.share_item_id);
|
|
4230
6028
|
doc.reference_doc = reference_doc;
|
|
4231
|
-
|
|
4232
|
-
doc
|
|
4233
|
-
...(doc.studio_meta || {}),
|
|
4234
|
-
pinned: reference_doc?.studio_meta?.pinned ?? doc?.studio_meta?.pinned,
|
|
4235
|
-
};
|
|
6029
|
+
// UI-221 / UI-222: the recipient's own marks win.
|
|
6030
|
+
apply_recipient_marks(doc, reference_doc);
|
|
4236
6031
|
}
|
|
4237
6032
|
|
|
4238
6033
|
doc.user_contact = await get_user_card({ uid, uid_query: doc.studio_meta.createdByUid });
|
|
@@ -5826,7 +7621,9 @@ const AI_FIELD_MAX_CONTEXT_KEYS = 12;
|
|
|
5826
7621
|
// Per-key overrides of the 1200-char cap. A sibling form field is a line or two,
|
|
5827
7622
|
// but a transcript is the whole point of the context it carries: cut it to 1200
|
|
5828
7623
|
// and the composer's helper answers the wrong message.
|
|
5829
|
-
|
|
7624
|
+
// UI-224: `email_thread` is the mail being replied to, quoted for the composer's helper. Same
|
|
7625
|
+
// reasoning as chat_history: the thread IS the context, so it gets room.
|
|
7626
|
+
const AI_FIELD_CONTEXT_VALUE_CAPS = { chat_history: 6000, email_thread: 6000 };
|
|
5830
7627
|
|
|
5831
7628
|
const AI_FIELD_PRESETS = {
|
|
5832
7629
|
agent_name: {
|
|
@@ -5871,6 +7668,42 @@ const AI_FIELD_PRESETS = {
|
|
|
5871
7668
|
'Never invent facts, numbers, dates or commitments the thread does not support. If something is genuinely unknown, ask for it instead of inventing it.',
|
|
5872
7669
|
],
|
|
5873
7670
|
},
|
|
7671
|
+
// UI-224: the two sparkles in the mail client's composer. The chat composer has had one for
|
|
7672
|
+
// a while (chat_message above) and the mail client had none, so writing an email was the one
|
|
7673
|
+
// place in the product where the AI could not help. These are separate presets rather than a
|
|
7674
|
+
// channel of chat_message because an email is two fields with different jobs: a subject line
|
|
7675
|
+
// has to survive being read in a list, and a body has to open, say the thing and sign off.
|
|
7676
|
+
// Both read `email_thread` (the mail being replied to) and the sibling field, so the subject
|
|
7677
|
+
// can be written from the body and the body from the subject.
|
|
7678
|
+
email_subject: {
|
|
7679
|
+
label: 'Subject',
|
|
7680
|
+
writes: 'the subject line of an email the user is about to send',
|
|
7681
|
+
single_line: true,
|
|
7682
|
+
max_chars: 120,
|
|
7683
|
+
improve_task: 'TASK: turn what is in the box into the subject line this email should carry. Keep what it is about and make it read as a subject rather than as a note to self.',
|
|
7684
|
+
generate_task: 'TASK: write the subject line for this email, from the body and the thread below. If the email is a reply, keep the thread\'s own subject and do not add another "Re:".',
|
|
7685
|
+
rules: [
|
|
7686
|
+
'Output the subject line only. No quotes, no "Subject:", no trailing period, no emoji.',
|
|
7687
|
+
'Under 10 words. Say what the mail is about, specifically, so it can be found in a list six months from now.',
|
|
7688
|
+
'Match the language the body and the thread are written in.',
|
|
7689
|
+
'Never invent a fact, a name, a number or a date that the body and the thread do not support.',
|
|
7690
|
+
],
|
|
7691
|
+
},
|
|
7692
|
+
email_body: {
|
|
7693
|
+
label: 'Message',
|
|
7694
|
+
writes: 'the body of an email the user is about to send',
|
|
7695
|
+
max_chars: 4000,
|
|
7696
|
+
improve_task:
|
|
7697
|
+
'TASK: turn what is in the box into the email the user is about to send. It is usually shorthand or a note to self, so expand it, write it out in full and address the recipient directly. Keep every specific they gave and add nothing they did not.',
|
|
7698
|
+
generate_task: 'TASK: write this email from scratch, from the subject and the thread below. If there is a thread, answer what was actually asked in it. If there is neither, write a short, natural opening email.',
|
|
7699
|
+
rules: [
|
|
7700
|
+
'Write the email itself, ready to send: a greeting, short paragraphs, a sign-off. No preamble, no "here is a draft", no subject line, no placeholders in brackets.',
|
|
7701
|
+
'Read the thread and continue it. Answer what was asked, refer to what was already said, and never repeat a point the thread has already made.',
|
|
7702
|
+
'Write as the user, in the first person, in the language the thread is using.',
|
|
7703
|
+
'Keep it short. Three short paragraphs at most unless the thread clearly needs more.',
|
|
7704
|
+
'Never invent facts, numbers, dates or commitments the thread does not support. If something is genuinely unknown, ask for it instead of inventing it.',
|
|
7705
|
+
],
|
|
7706
|
+
},
|
|
5874
7707
|
agent_user_guide: {
|
|
5875
7708
|
label: 'User guide',
|
|
5876
7709
|
writes: 'a short guide shown to the people who will USE an AI agent, next to the chat box',
|
|
@@ -6457,6 +8290,83 @@ const email_binding_error = async function (account_profile_info) {
|
|
|
6457
8290
|
return new Error(`${here} has no mailbox, so it cannot send email. Open Email to add one, then attach it to this profile.`);
|
|
6458
8291
|
};
|
|
6459
8292
|
|
|
8293
|
+
// UI-223. Is this a CODE chat or a regular one? Code chats are the ones whose turns run commands
|
|
8294
|
+
// on a machine: a project's VPS, a full stack VPS, a mini app (studio), a static site's source.
|
|
8295
|
+
// Everything else (a contact, an agent, a profile, the dashboard's own composer) is a regular
|
|
8296
|
+
// chat. Decided once at creation and stored, so the two lists are a plain equality filter rather
|
|
8297
|
+
// than a join against the app catalog on every read.
|
|
8298
|
+
const CODE_CHAT_APP_TYPES = ['vps', 'external_vps', 'static_website'];
|
|
8299
|
+
const code_chat_class = async function (req, reference_type, reference_id) {
|
|
8300
|
+
try {
|
|
8301
|
+
// Mini apps are REGULAR chats (Boaz, 2026-08-13: "Mini app chats should be like the regular
|
|
8302
|
+
// chats (not code)"). A studio chat asks an AI builder to write program documents, so it has no
|
|
8303
|
+
// machine, no commands and no files: everything the Code surface exists to show is absent, and
|
|
8304
|
+
// its own Apps tab already knows how to present it.
|
|
8305
|
+
if (reference_type === 'apps' || req.conversation_type === 'studio') return 'chat';
|
|
8306
|
+
if (reference_type !== 'dashboard') return 'chat';
|
|
8307
|
+
// The client tells us directly when the user is in vibe mode, which is a code chat whatever
|
|
8308
|
+
// the project turns out to be.
|
|
8309
|
+
const ctx = req.context || req.metadata?.context;
|
|
8310
|
+
if (ctx && typeof ctx === 'object' && ctx.vibe_mode === true) return 'code';
|
|
8311
|
+
const app_id = req.app_id || reference_id;
|
|
8312
|
+
if (!app_id) return 'chat';
|
|
8313
|
+
const app_obj = (await db_module.get_couch_doc('xuda_master', app_id)).data;
|
|
8314
|
+
if (!app_obj) return 'chat';
|
|
8315
|
+
if (app_obj.is_full_stack) return 'code';
|
|
8316
|
+
return CODE_CHAT_APP_TYPES.includes(app_obj.app_type) ? 'code' : 'chat';
|
|
8317
|
+
} catch (err) {
|
|
8318
|
+
// A chat that cannot be classified belongs in the list everything else is in, never hidden.
|
|
8319
|
+
return 'chat';
|
|
8320
|
+
}
|
|
8321
|
+
};
|
|
8322
|
+
|
|
8323
|
+
// The code list is an equality filter, so a chat created before `chat_class` existed would never
|
|
8324
|
+
// appear in it (the regular list keeps showing it, because $ne matches a missing field, which is
|
|
8325
|
+
// why nothing disappears in the meantime). Rather than a fleet-wide migration, each account is
|
|
8326
|
+
// backfilled once, the first time it asks for its code chats. Bounded and marked in the cache, so
|
|
8327
|
+
// it costs one pass per account and nothing after that.
|
|
8328
|
+
const CHAT_CLASS_BACKFILL_LIMIT = 500;
|
|
8329
|
+
// The marker is versioned because the RULE can change: mini apps were briefly classified as code
|
|
8330
|
+
// and are now regular, so an account already marked done has to be walked again to correct what the
|
|
8331
|
+
// old rule stamped. Bump this when code_chat_class changes what an existing chat should be.
|
|
8332
|
+
const CHAT_CLASS_RULE_VERSION = 2;
|
|
8333
|
+
const ensure_chat_class_backfilled = async function (uid, app_id) {
|
|
8334
|
+
const marker = `chat_class_backfill_v${CHAT_CLASS_RULE_VERSION}_${app_id}`;
|
|
8335
|
+
try {
|
|
8336
|
+
if (await db_module.get_memcached_doc(marker)) return;
|
|
8337
|
+
} catch (err) {}
|
|
8338
|
+
let stamped = 0;
|
|
8339
|
+
try {
|
|
8340
|
+
// Two kinds of doc need a pass: never stamped, and stamped `code` by an older rule. The second
|
|
8341
|
+
// selector is narrow on purpose (only the class this rule moved), so re-running never re-walks
|
|
8342
|
+
// an account's whole history.
|
|
8343
|
+
const missing = await db_module.find_app_couch_query(app_id, {
|
|
8344
|
+
selector: { docType: 'chat_conversation', chat_class: { $exists: false } },
|
|
8345
|
+
limit: CHAT_CLASS_BACKFILL_LIMIT,
|
|
8346
|
+
});
|
|
8347
|
+
const stale = await db_module.find_app_couch_query(app_id, {
|
|
8348
|
+
selector: { docType: 'chat_conversation', chat_class: 'code' },
|
|
8349
|
+
limit: CHAT_CLASS_BACKFILL_LIMIT,
|
|
8350
|
+
});
|
|
8351
|
+
const q = { docs: [...(missing?.docs || []), ...(stale?.docs || [])] };
|
|
8352
|
+
for (const doc of q?.docs || []) {
|
|
8353
|
+
const chat_class = await code_chat_class({ context: doc.context, conversation_type: doc.conversation_type, app_id: doc.reference_id }, doc.reference_type, doc.reference_id);
|
|
8354
|
+
if (doc.chat_class === chat_class) continue;
|
|
8355
|
+
doc.chat_class = chat_class;
|
|
8356
|
+
await db_module.save_app_couch_doc(app_id, doc);
|
|
8357
|
+
stamped += 1;
|
|
8358
|
+
}
|
|
8359
|
+
// Only mark it done when neither pass was cut short by the limit, so a big account finishes
|
|
8360
|
+
// over a few visits instead of being written off after the first 500.
|
|
8361
|
+
if ((missing?.docs || []).length < CHAT_CLASS_BACKFILL_LIMIT && (stale?.docs || []).length < CHAT_CLASS_BACKFILL_LIMIT) {
|
|
8362
|
+
await db_module.set_memcached_doc(marker, { done_ts: Date.now(), stamped }, 30 * 24 * 60 * 60);
|
|
8363
|
+
}
|
|
8364
|
+
if (stamped) console.log(`[chat_class] backfilled ${stamped} conversation(s) for ${app_id}`);
|
|
8365
|
+
} catch (err) {
|
|
8366
|
+
console.error(`[chat_class] backfill ${app_id} failed: ${err?.message || err}`);
|
|
8367
|
+
}
|
|
8368
|
+
};
|
|
8369
|
+
|
|
6460
8370
|
export const create_conversation = async function (req, job_id, headers) {
|
|
6461
8371
|
const { profile_id, uid, prompt, perform_ai_execution = true, email_id, direction = 'out', from_mailbox, date_created, ai_model = _conf.default_ai_model, plan_mode = false } = req;
|
|
6462
8372
|
let { reference_type, reference_id = '', conversation_type, email_recipient_type } = req;
|
|
@@ -6519,6 +8429,12 @@ export const create_conversation = async function (req, job_id, headers) {
|
|
|
6519
8429
|
email_recipient_type,
|
|
6520
8430
|
model: ai_model,
|
|
6521
8431
|
plan_mode: normalize_boolean(plan_mode),
|
|
8432
|
+
// UI-223: which of the two chat worlds this belongs to. A code chat (a project's VPS, a full
|
|
8433
|
+
// stack VPS, a mini app, a static site) behaves nothing like a chat with a contact or an
|
|
8434
|
+
// agent: it runs commands on a machine, takes minutes, and has files and a run history to
|
|
8435
|
+
// show. They are listed and opened separately, and this is what the lists filter on. Set at
|
|
8436
|
+
// creation because it is a property of what the chat IS, not of any one turn.
|
|
8437
|
+
chat_class: await code_chat_class(req, reference_type, reference_id),
|
|
6522
8438
|
};
|
|
6523
8439
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
6524
8440
|
|
|
@@ -7047,6 +8963,12 @@ const contact_chat_conversation = async function (req, job_id, headers) {
|
|
|
7047
8963
|
conversation_item_reference_id,
|
|
7048
8964
|
direction: 'out',
|
|
7049
8965
|
role: 'user',
|
|
8966
|
+
// A message I just sent is read by me, by definition. The contact card's badge is
|
|
8967
|
+
// (items on this contact) minus (items I have read), and the only other thing that
|
|
8968
|
+
// ever stamps `read` is opening the conversation. Leave it off here and my own
|
|
8969
|
+
// outgoing message sits in the badge forever unless I happen to re-open the thread it
|
|
8970
|
+
// started, which is exactly the "opened everything, still says 1" report.
|
|
8971
|
+
read: { [uid]: Date.now() },
|
|
7050
8972
|
};
|
|
7051
8973
|
|
|
7052
8974
|
const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
|
|
@@ -7526,6 +9448,13 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
7526
9448
|
// UI-155: the template the user picked in the composer, for THIS message only. Empty on
|
|
7527
9449
|
// every other path, which leaves the address's saved default in charge.
|
|
7528
9450
|
const composed_style = String(req.template_style || '').trim();
|
|
9451
|
+
// UI-224: the mail client sends its own message (one mailbox, many recipients, Cc and Bcc),
|
|
9452
|
+
// and then files it on the thread of every recipient it knows as a contact. That filing is
|
|
9453
|
+
// this function minus the delivery: the item, the threading and the timeline are wanted,
|
|
9454
|
+
// a second copy of the mail is not. Distinct from `from_mailbox`, which also means "already
|
|
9455
|
+
// delivered" but marks the message as one the MAILBOX brought in and lets an auto response
|
|
9456
|
+
// answer it, neither of which is true of a mail we just sent ourselves.
|
|
9457
|
+
const skip_send = req.skip_send === true;
|
|
7529
9458
|
try {
|
|
7530
9459
|
if (!account_profile_info.account_profile_obj?.email_account_id) {
|
|
7531
9460
|
throw await email_binding_error(account_profile_info);
|
|
@@ -7590,12 +9519,32 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
7590
9519
|
if (composed_subject) {
|
|
7591
9520
|
subject = composed_subject;
|
|
7592
9521
|
} else if (!last_email_item) {
|
|
7593
|
-
|
|
9522
|
+
// UI-216 follow-up (Boaz: "generate auto subject for new email (not in thread)"). The
|
|
9523
|
+
// FIRST email of a thread had a subject only when the body ran past ten words. Under
|
|
9524
|
+
// that it fell through with none, and `subject` stayed whatever titled the conversation,
|
|
9525
|
+
// which for a plain send is the body itself. That gate was survivable while every email
|
|
9526
|
+
// went through the composer, where the user saw and approved a subject before anything
|
|
9527
|
+
// left. Now that send means send, a short message is the common case, and a short
|
|
9528
|
+
// message is exactly the one that arrives looking like nothing: no subject at all, or
|
|
9529
|
+
// the message repeated as its own subject.
|
|
9530
|
+
//
|
|
9531
|
+
// Every new email gets one now. The split is about what it COSTS, not whether to bother:
|
|
9532
|
+
// a body with something to summarise is worth an AI call, and a one-liner already IS its
|
|
9533
|
+
// own subject, so it is cleaned up rather than sent to a model to be told so.
|
|
9534
|
+
const words = String(body || '').trim().split(/\s+/).filter(Boolean);
|
|
9535
|
+
if (words.length > 10) {
|
|
7594
9536
|
const subject_ret = await submit_chat_gpt_prompt({ uid, prompt: `create title for the prompt return 5 words result maximum text only without options : ${body}`, model: _conf.default_ai_model, metadata: { conversation_id: conversation_doc._id, func: 'chat_email' }, account_profile_info });
|
|
7595
9537
|
if (subject_ret.code < 0) {
|
|
7596
9538
|
throw new Error('something wrong creating email subject');
|
|
7597
9539
|
}
|
|
7598
9540
|
subject = subject_ret.data;
|
|
9541
|
+
} else if (words.length) {
|
|
9542
|
+
// The first line, because a two line note should not put the second one in the
|
|
9543
|
+
// subject; capped at the length a mail client will show; first letter raised so
|
|
9544
|
+
// "test email" arrives as "Test email" rather than looking unfinished.
|
|
9545
|
+
const first_line = String(body).split(/\r?\n/).find((line) => line.trim()) || '';
|
|
9546
|
+
const trimmed = first_line.trim().replace(/\s+/g, ' ').slice(0, 60).replace(/[\s,;:.\-]+$/, '');
|
|
9547
|
+
subject = trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : subject;
|
|
7599
9548
|
}
|
|
7600
9549
|
} else {
|
|
7601
9550
|
// UI-145: an email inside an existing conversation is a reply, which this path already
|
|
@@ -7618,53 +9567,58 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
7618
9567
|
subject = thread_subject ? 'Re: ' + thread_subject : '';
|
|
7619
9568
|
}
|
|
7620
9569
|
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
9570
|
+
// UI-224: everything from here to the end of the block is the DELIVERY. A filing call
|
|
9571
|
+
// has already delivered the mail itself, so it stops here and keeps the subject the
|
|
9572
|
+
// threading above worked out.
|
|
9573
|
+
if (!skip_send) {
|
|
9574
|
+
let email_attachments = [];
|
|
9575
|
+
for (const attachment of attachments) {
|
|
9576
|
+
email_attachments.push(await save_drive_file_to_disk(attachment));
|
|
9577
|
+
}
|
|
7625
9578
|
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
9579
|
+
// Wrap the body with the profile's selected email template (Email
|
|
9580
|
+
// Template tab). 'plain' or an unset style renders null, keeping the
|
|
9581
|
+
// original raw-text behavior.
|
|
9582
|
+
let template_html = null;
|
|
9583
|
+
try {
|
|
9584
|
+
const profile_doc = account_profile_info.account_profile_obj || {};
|
|
9585
|
+
const render_ret = await email_ms.render_profile_email({
|
|
9586
|
+
// The template and signature live on the attached email account now; the profile
|
|
9587
|
+
// fields below are the fallback for a profile configured before the Email hub.
|
|
9588
|
+
uid,
|
|
9589
|
+
email_account_id: profile_doc.email_account_id,
|
|
9590
|
+
style: profile_doc.email_template?.style,
|
|
9591
|
+
// UI-155: a per-message choice made in the composer, which outranks the address's
|
|
9592
|
+
// saved default inside the renderer.
|
|
9593
|
+
...(composed_style ? { style_override: composed_style } : {}),
|
|
9594
|
+
body_text: body,
|
|
9595
|
+
// A composed body is already HTML the user laid out (bold, lists, links), so the
|
|
9596
|
+
// template has to drop it in as-is. Escaping it into paragraphs the way a plain
|
|
9597
|
+
// body is treated would put the tags on screen as text.
|
|
9598
|
+
...(composed_html ? { body_html: composed_html } : {}),
|
|
9599
|
+
profile_name: profile_doc.profile_name,
|
|
9600
|
+
signature: profile_doc.profile_signature,
|
|
9601
|
+
avatar_url: profile_doc.profile_picture || profile_doc.profile_avatar || '',
|
|
9602
|
+
brand_color: profile_doc.widget_config?.color || '',
|
|
9603
|
+
});
|
|
9604
|
+
if (render_ret?.code > 0 && render_ret.data) template_html = render_ret.data;
|
|
9605
|
+
} catch (err) {}
|
|
7653
9606
|
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
9607
|
+
// With no template style selected there is nothing wrapping the body, so the composed
|
|
9608
|
+
// HTML is the whole message. Without this it would fall through to sendEmailFromAccount's
|
|
9609
|
+
// "wrap the plain text in one <p>" default and the formatting would be lost.
|
|
9610
|
+
// UI-157: cc / bcc come from the composer and are normalized inside sendEmailFromAccount,
|
|
9611
|
+
// so anything that is not an address is dropped rather than reaching the SMTP server.
|
|
9612
|
+
sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html || composed_html || null, email_attachments, {
|
|
9613
|
+
cc: req.cc,
|
|
9614
|
+
bcc: req.bcc,
|
|
9615
|
+
});
|
|
9616
|
+
if (!sent_email_result.success) {
|
|
9617
|
+
throw new Error('error sending email');
|
|
9618
|
+
}
|
|
9619
|
+
for await (const attachment of email_attachments) {
|
|
9620
|
+
await rm(attachment.path, { force: true });
|
|
9621
|
+
}
|
|
7668
9622
|
}
|
|
7669
9623
|
}
|
|
7670
9624
|
|
|
@@ -7698,6 +9652,9 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
7698
9652
|
conversation_item_reference_id,
|
|
7699
9653
|
direction,
|
|
7700
9654
|
role: 'user',
|
|
9655
|
+
// Same rule as the chat send: mail I sent is already read by me. Inbound mail
|
|
9656
|
+
// (the mailbox path) is left alone, that one really is waiting to be looked at.
|
|
9657
|
+
...(direction === 'out' ? { read: { [uid]: Date.now() } } : {}),
|
|
7701
9658
|
email_id,
|
|
7702
9659
|
subject,
|
|
7703
9660
|
last_email_item_id: last_email_item?._id,
|
|
@@ -7718,24 +9675,105 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
7718
9675
|
}
|
|
7719
9676
|
}
|
|
7720
9677
|
|
|
7721
|
-
if (conversation_doc.reference_type === 'contacts' && from_mailbox) {
|
|
7722
|
-
auto_response(uid, profile_id, conversation_doc.reference_id, 'email');
|
|
9678
|
+
if (conversation_doc.reference_type === 'contacts' && from_mailbox) {
|
|
9679
|
+
auto_response(uid, profile_id, conversation_doc.reference_id, 'email');
|
|
9680
|
+
}
|
|
9681
|
+
|
|
9682
|
+
return save_ret;
|
|
9683
|
+
} catch (err) {
|
|
9684
|
+
// UI-161: this used to swallow the reason, so a send that died anywhere in here surfaced
|
|
9685
|
+
// as a bare CouchDB `{ message: 'missing' }` on stderr with nothing tying it to a
|
|
9686
|
+
// conversation, a contact or a mailbox. The job then ended and no email went out.
|
|
9687
|
+
console.error(`[chat_email] failed: conversation_id=${conversation_doc?._id} contact=${conversation_doc?.reference_id} app_id=${account_profile_info?.app_id} profile=${account_profile_info?.account_profile_obj?._id} mailbox=${account_profile_info?.account_profile_obj?.email_account_id}`, err?.stack || err?.message || err);
|
|
9688
|
+
// UI-161: an expired or revoked OAuth grant is the single most common way sending stops
|
|
9689
|
+
// working, and "Failed to obtain valid access token" tells the person reading it nothing
|
|
9690
|
+
// about what to do. Name the mailbox and the fix.
|
|
9691
|
+
if (/access token|invalid_grant|unauthorized|invalid credentials/i.test(String(err?.message || ''))) {
|
|
9692
|
+
const mailbox_address = email_account_doc?.email || account_profile_info?.account_profile_obj?.email_account_id || 'this mailbox';
|
|
9693
|
+
return { code: -15, data: `Xuda can no longer sign in to ${mailbox_address}, so the email was not sent. Open Email, go to Mailboxes, and reconnect the account.` };
|
|
9694
|
+
}
|
|
9695
|
+
return { code: -15, data: err.message };
|
|
9696
|
+
}
|
|
9697
|
+
};
|
|
9698
|
+
|
|
9699
|
+
// UI-224: file a mail the MAIL CLIENT already sent onto a contact's email thread. The client
|
|
9700
|
+
// composes to several people at once and can send from a mailbox that belongs to another
|
|
9701
|
+
// profile, so it does the delivery itself (email_module's send_email_message) and calls this
|
|
9702
|
+
// once per recipient it knows as a contact. Everything the timeline needs then comes out of
|
|
9703
|
+
// the same chat_email the contact composer uses, with the delivery switched off, so a mail
|
|
9704
|
+
// sent from the client and one sent from a contact conversation leave identical records.
|
|
9705
|
+
//
|
|
9706
|
+
// The thread is the contact's newest email conversation, and one is opened when there is
|
|
9707
|
+
// none, which is what makes a first mail to a contact appear as a conversation rather than
|
|
9708
|
+
// as nothing at all.
|
|
9709
|
+
export const log_sent_email = async function (req = {}, job_id, headers) {
|
|
9710
|
+
const { uid, profile_id, contact_id } = req;
|
|
9711
|
+
try {
|
|
9712
|
+
if (!contact_id) throw new Error('missing contact_id');
|
|
9713
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
9714
|
+
const app_id = account_profile_info.app_id;
|
|
9715
|
+
const subject = String(req.subject || '').trim();
|
|
9716
|
+
const body = String(req.body_text || '').trim() || subject;
|
|
9717
|
+
const body_html = String(req.body_html || '');
|
|
9718
|
+
|
|
9719
|
+
const found = await db_module.find_app_couch_query(app_id, {
|
|
9720
|
+
selector: {
|
|
9721
|
+
docType: 'chat_conversation',
|
|
9722
|
+
reference_type: 'contacts',
|
|
9723
|
+
reference_id: contact_id,
|
|
9724
|
+
conversation_type: 'email',
|
|
9725
|
+
stat: { $lt: 4 },
|
|
9726
|
+
},
|
|
9727
|
+
limit: 1,
|
|
9728
|
+
sort: [{ ts: 'desc' }],
|
|
9729
|
+
});
|
|
9730
|
+
|
|
9731
|
+
let conversation_id = found?.docs?.[0]?._id || '';
|
|
9732
|
+
if (!conversation_id) {
|
|
9733
|
+
// perform_ai_execution off: opening a thread to record a mail that has already gone is
|
|
9734
|
+
// bookkeeping, and the title, category and picture passes belong to a conversation
|
|
9735
|
+
// somebody is actually having.
|
|
9736
|
+
const created = await create_conversation(
|
|
9737
|
+
{
|
|
9738
|
+
uid,
|
|
9739
|
+
profile_id,
|
|
9740
|
+
reference_type: 'contacts',
|
|
9741
|
+
reference_id: contact_id,
|
|
9742
|
+
conversation_type: 'email',
|
|
9743
|
+
prompt: body,
|
|
9744
|
+
subject,
|
|
9745
|
+
direction: 'out',
|
|
9746
|
+
perform_ai_execution: false,
|
|
9747
|
+
},
|
|
9748
|
+
job_id,
|
|
9749
|
+
headers,
|
|
9750
|
+
);
|
|
9751
|
+
if (created?.code < 0) throw new Error(created.data);
|
|
9752
|
+
conversation_id = created?.data?.id || created?.data?._id || '';
|
|
9753
|
+
if (!conversation_id) throw new Error('could not open an email conversation for this contact');
|
|
7723
9754
|
}
|
|
7724
9755
|
|
|
7725
|
-
|
|
9756
|
+
const ret = await submit_chat_conversation(
|
|
9757
|
+
{
|
|
9758
|
+
uid,
|
|
9759
|
+
profile_id,
|
|
9760
|
+
conversation_id,
|
|
9761
|
+
prompt: body,
|
|
9762
|
+
subject,
|
|
9763
|
+
...(body_html ? { body_html } : {}),
|
|
9764
|
+
direction: 'out',
|
|
9765
|
+
perform_ai_execution: false,
|
|
9766
|
+
// The mail is already out. This call is the record of it, not a second send.
|
|
9767
|
+
skip_send: true,
|
|
9768
|
+
},
|
|
9769
|
+
job_id,
|
|
9770
|
+
headers,
|
|
9771
|
+
);
|
|
9772
|
+
if (ret?.code < 0) throw new Error(ret.data);
|
|
9773
|
+
return { code: 1, data: { conversation_id } };
|
|
7726
9774
|
} catch (err) {
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
// conversation, a contact or a mailbox. The job then ended and no email went out.
|
|
7730
|
-
console.error(`[chat_email] failed: conversation_id=${conversation_doc?._id} contact=${conversation_doc?.reference_id} app_id=${account_profile_info?.app_id} profile=${account_profile_info?.account_profile_obj?._id} mailbox=${account_profile_info?.account_profile_obj?.email_account_id}`, err?.stack || err?.message || err);
|
|
7731
|
-
// UI-161: an expired or revoked OAuth grant is the single most common way sending stops
|
|
7732
|
-
// working, and "Failed to obtain valid access token" tells the person reading it nothing
|
|
7733
|
-
// about what to do. Name the mailbox and the fix.
|
|
7734
|
-
if (/access token|invalid_grant|unauthorized|invalid credentials/i.test(String(err?.message || ''))) {
|
|
7735
|
-
const mailbox_address = email_account_doc?.email || account_profile_info?.account_profile_obj?.email_account_id || 'this mailbox';
|
|
7736
|
-
return { code: -15, data: `Xuda can no longer sign in to ${mailbox_address}, so the email was not sent. Open Email, go to Mailboxes, and reconnect the account.` };
|
|
7737
|
-
}
|
|
7738
|
-
return { code: -15, data: err.message };
|
|
9775
|
+
console.error(`[log_sent_email] contact=${contact_id} uid=${uid}: ${err?.message || err}`);
|
|
9776
|
+
return { code: -15, data: err.message || String(err) };
|
|
7739
9777
|
}
|
|
7740
9778
|
};
|
|
7741
9779
|
|
|
@@ -8755,6 +10793,79 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
8755
10793
|
const vps_ip = vibe_app_obj?.app_hosting_server?.ip;
|
|
8756
10794
|
const project_name = vibe_app_obj?.menuName || vibe_app_obj?.app_name || vibe_app_obj?.name || '';
|
|
8757
10795
|
|
|
10796
|
+
// ── UI-226: the repository engine, and ONLY from the Code tab ──────────────────────────
|
|
10797
|
+
// Boaz: "the git thing is only for the code tab, vps and etc stay the same". So the SURFACE
|
|
10798
|
+
// decides, not the project: the code workspace sends `code_surface`, and only then does a
|
|
10799
|
+
// project with a connected repo run against a working copy instead of its machine. A chat
|
|
10800
|
+
// opened from the project's own AI panel never takes this path, so VPS work is untouched.
|
|
10801
|
+
const from_code_surface = get_dashboard_context_obj(req, conversation_doc)?.code_surface === true;
|
|
10802
|
+
const project_repo = from_code_surface ? (await git_repo_list({ uid, profile_id, app_id: target_app_id })).data?.repos?.[0] : null;
|
|
10803
|
+
if (project_repo) {
|
|
10804
|
+
const repo_full = await load_git_repo(uid, profile_id, project_repo._id);
|
|
10805
|
+
const prepared = repo_full.repo ? await ensure_git_working_copy({ uid, repo: repo_full.repo, conversation_id }) : { error: 'repository not found' };
|
|
10806
|
+
if (prepared.error) {
|
|
10807
|
+
emitToDashboard('response_start');
|
|
10808
|
+
streamText(prepared.error);
|
|
10809
|
+
emitToDashboard('stream_end', undefined, { error: true });
|
|
10810
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
10811
|
+
conversation_doc.ts = Date.now();
|
|
10812
|
+
conversation_doc.stat = 3;
|
|
10813
|
+
conversation_doc.process_stat = 'partial';
|
|
10814
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
10815
|
+
return await saveAssistantItem(prepared.error, { is_request_error: true });
|
|
10816
|
+
}
|
|
10817
|
+
|
|
10818
|
+
// The prompt says what this place is and what the agent must NOT do. Committing is the
|
|
10819
|
+
// user's action from the panel (Claude Code's contract), and a commit made here would also
|
|
10820
|
+
// be made with credentials the agent should never be handed.
|
|
10821
|
+
const repo_context =
|
|
10822
|
+
`You are working in a git working copy of the repository ${repo_full.repo.remote_url.replace(/\/\/[^@]*@/, '//')} for Xuda project "${project_name || target_app_id}".\n` +
|
|
10823
|
+
`Your current working directory is the checkout. The branch ${prepared.branch} is already checked out for you.\n` +
|
|
10824
|
+
`Edit files directly with your normal file tools. Do NOT run git: no commit, no push, no branch, no stash, no checkout. The person you are working with reviews your changes and commits them from Xuda, so leave the working tree dirty and simply describe what you changed.\n` +
|
|
10825
|
+
`Do not deploy or publish anything either.`;
|
|
10826
|
+
const repo_prompt = `[System]\n${repo_context}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
|
|
10827
|
+
|
|
10828
|
+
const repo_resume_id = conversation_doc.codex_session_id && conversation_doc.codex_session_host === code_run_host() ? conversation_doc.codex_session_id : null;
|
|
10829
|
+
if (job_id) {
|
|
10830
|
+
try {
|
|
10831
|
+
await jobs_ms.update_job({ job_id, is_background: true, current_step_name: 'streaming results' });
|
|
10832
|
+
} catch (e) {}
|
|
10833
|
+
}
|
|
10834
|
+
|
|
10835
|
+
const repo_run_ret = await start_code_run({
|
|
10836
|
+
uid,
|
|
10837
|
+
profile_id,
|
|
10838
|
+
app_id: account_profile_info.app_id,
|
|
10839
|
+
target_app_id,
|
|
10840
|
+
conversation_id,
|
|
10841
|
+
conversation_doc,
|
|
10842
|
+
reference_id: conversation_doc.reference_id,
|
|
10843
|
+
job_id,
|
|
10844
|
+
prompt: repo_prompt,
|
|
10845
|
+
prompt_conversation_item_id,
|
|
10846
|
+
response_conversation_item_id,
|
|
10847
|
+
local_cwd: prepared.dir,
|
|
10848
|
+
watch_paths: [prepared.dir],
|
|
10849
|
+
app_obj: vibe_app_obj,
|
|
10850
|
+
attachments,
|
|
10851
|
+
codex_model: req.codex_model || req.ai_model,
|
|
10852
|
+
resume_session_id: repo_resume_id,
|
|
10853
|
+
repo_id: repo_full.repo._id,
|
|
10854
|
+
branch: prepared.branch,
|
|
10855
|
+
base_sha: prepared.base_sha,
|
|
10856
|
+
pre_dirty: prepared.pre_dirty,
|
|
10857
|
+
});
|
|
10858
|
+
|
|
10859
|
+
if (repo_run_ret.code < 0) {
|
|
10860
|
+
const failed_msg = "I couldn't start that just now. Please try again in a moment.";
|
|
10861
|
+
emitToDashboard('response_start');
|
|
10862
|
+
streamText(failed_msg);
|
|
10863
|
+
emitToDashboard('stream_end', undefined, { error: true });
|
|
10864
|
+
return await saveAssistantItem(failed_msg, { is_request_error: true });
|
|
10865
|
+
}
|
|
10866
|
+
return { code: 1, job_self_finalizes: true, data: { id: response_conversation_item_id, code_run_id: repo_run_ret.data?.run_id, state: repo_run_ret.data?.state } };
|
|
10867
|
+
}
|
|
10868
|
+
|
|
8758
10869
|
// Plan A: static websites have no VPS — codex edits the source on the
|
|
8759
10870
|
// pinned studio filesystem directly (local-fs mode), no SSH. The source
|
|
8760
10871
|
// dir is <studio_drive_path>/<account_project_id>/<source.folder_path>.
|
|
@@ -8788,25 +10899,46 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
8788
10899
|
}
|
|
8789
10900
|
const sw_context = `You are operating in vibe mode for the Xuda static website "${project_name || target_app_id}" (app_id: ${target_app_id}).\nThe site's source files are in your current working directory — edit them directly. This is the editable source, not the live site; after you finish, tell the user to click Publish to ship it live. Do not deploy or publish anything yourself.`;
|
|
8790
10901
|
const sw_prompt = `[System]\n${sw_context}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
//
|
|
8794
|
-
|
|
8795
|
-
if (
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
const
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
|
|
8805
|
-
|
|
8806
|
-
|
|
8807
|
-
|
|
8808
|
-
|
|
8809
|
-
|
|
10902
|
+
// UI-220: same durable run as the VPS path, with the site's source dir as the working
|
|
10903
|
+
// tree instead of a remote host. A site edit is where the file list is at its most useful,
|
|
10904
|
+
// since Codex works through apply_patch here rather than shell commands.
|
|
10905
|
+
const sw_resume_id = conversation_doc.codex_session_id && conversation_doc.codex_session_host === code_run_host() ? conversation_doc.codex_session_id : null;
|
|
10906
|
+
if (job_id) {
|
|
10907
|
+
try {
|
|
10908
|
+
await jobs_ms.update_job({ job_id, is_background: true, current_step_name: 'streaming results' });
|
|
10909
|
+
} catch (e) {}
|
|
10910
|
+
}
|
|
10911
|
+
const sw_run_ret = await start_code_run({
|
|
10912
|
+
uid,
|
|
10913
|
+
profile_id,
|
|
10914
|
+
app_id: account_profile_info.app_id,
|
|
10915
|
+
target_app_id,
|
|
10916
|
+
conversation_id,
|
|
10917
|
+
conversation_doc,
|
|
10918
|
+
reference_id: conversation_doc.reference_id,
|
|
10919
|
+
job_id,
|
|
10920
|
+
prompt: sw_prompt,
|
|
10921
|
+
prompt_conversation_item_id,
|
|
10922
|
+
response_conversation_item_id,
|
|
10923
|
+
local_cwd: site_dir,
|
|
10924
|
+
app_obj: vibe_app_obj,
|
|
10925
|
+
attachments,
|
|
10926
|
+
codex_model: req.codex_model || req.ai_model,
|
|
10927
|
+
resume_session_id: sw_resume_id,
|
|
10928
|
+
});
|
|
10929
|
+
if (sw_run_ret.code < 0) {
|
|
10930
|
+
const sw_failed_msg = "I couldn't start that change just now. Please try again in a moment.";
|
|
10931
|
+
emitToDashboard('response_start');
|
|
10932
|
+
streamText(sw_failed_msg);
|
|
10933
|
+
emitToDashboard('stream_end', undefined, { error: true });
|
|
10934
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
10935
|
+
conversation_doc.ts = Date.now();
|
|
10936
|
+
conversation_doc.stat = 3;
|
|
10937
|
+
conversation_doc.process_stat = 'partial';
|
|
10938
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
10939
|
+
return await saveAssistantItem(sw_failed_msg, { is_request_error: true });
|
|
10940
|
+
}
|
|
10941
|
+
return { code: 1, job_self_finalizes: true, data: { id: response_conversation_item_id, code_run_id: sw_run_ret.data?.run_id, state: sw_run_ret.data?.state } };
|
|
8810
10942
|
}
|
|
8811
10943
|
|
|
8812
10944
|
if (!vps_ip) {
|
|
@@ -8869,63 +11001,70 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
8869
11001
|
: '');
|
|
8870
11002
|
const codex_prompt = `[System]\n${project_context_block}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
|
|
8871
11003
|
|
|
8872
|
-
//
|
|
8873
|
-
//
|
|
8874
|
-
//
|
|
8875
|
-
//
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
attachments,
|
|
8884
|
-
stream: false,
|
|
8885
|
-
// Deliberate: this build runs on the CUSTOMER'S OWN VPS over SSH and has to install
|
|
8886
|
-
// packages, write outside a workspace and restart services there, so it keeps full
|
|
8887
|
-
// access. Stated explicitly now that the global default is confined, so the choice
|
|
8888
|
-
// is visible instead of inherited. Nothing on Xuda infrastructure may set this.
|
|
8889
|
-
bypass_sandbox: true,
|
|
8890
|
-
...(agent_key ? { agent_mcp: { url: 'http://localhost:3012/mcp', bearer_env_var: 'XUDA_AGENT_KEY', bearer_value: agent_key } } : {}),
|
|
8891
|
-
},
|
|
8892
|
-
job_id,
|
|
8893
|
-
headers,
|
|
8894
|
-
);
|
|
8895
|
-
// The ephemeral scoped key has served its purpose; revoke it so it can't
|
|
8896
|
-
// outlive the run. A Full Stack VPS's persistent key is kept (not ephemeral).
|
|
8897
|
-
if (agent_key && agent_key_ephemeral) {
|
|
11004
|
+
// UI-220. The run is started, not awaited. It becomes a detached process with its own
|
|
11005
|
+
// JSONL log and `code_run` doc, so it survives this process, streams while it works, and
|
|
11006
|
+
// finishes the conversation itself (see finalize_code_run). This turn's job is marked
|
|
11007
|
+
// background so the 10 minute job sweeper leaves it alone; the run closes it at the end.
|
|
11008
|
+
//
|
|
11009
|
+
// The Codex THREAD is the chat's own: a session recorded on this host is resumed, so turn 2
|
|
11010
|
+
// already knows what turn 1 did to the box instead of rediscovering it. A session from
|
|
11011
|
+
// another host (region cutover) falls back to a cold start rather than failing.
|
|
11012
|
+
const resume_session_id = conversation_doc.codex_session_id && conversation_doc.codex_session_host === code_run_host() ? conversation_doc.codex_session_id : null;
|
|
11013
|
+
|
|
11014
|
+
if (job_id) {
|
|
8898
11015
|
try {
|
|
8899
|
-
await
|
|
11016
|
+
await jobs_ms.update_job({ job_id, is_background: true, current_step_name: 'streaming results' });
|
|
8900
11017
|
} catch (e) {}
|
|
8901
11018
|
}
|
|
8902
|
-
const vibe_failed = codex_ret.code < 0;
|
|
8903
|
-
// Never echo the raw codex/SSH error to the client — it leaks the remote
|
|
8904
|
-
// host, the full command (MCP url, bearer env var, model, dangerous flags)
|
|
8905
|
-
// and provider billing details. Log it server-side; the client gets a
|
|
8906
|
-
// clean message rendered as an error card (error flag below).
|
|
8907
|
-
if (vibe_failed) console.error('[vibe] VPS codex request failed:', get_error_message(codex_ret.data, 'codex error'));
|
|
8908
|
-
const codex_events = codex_ret?.data?.events || [];
|
|
8909
|
-
const last_message = [...codex_events].reverse().find((event) => event?.item?.type === 'agent_message' && event.item.text)?.item?.text;
|
|
8910
|
-
const raw_response_text = vibe_failed ? "I couldn't finish that request just now. Please try again in a moment." : last_message || 'Vibe request completed.';
|
|
8911
|
-
|
|
8912
|
-
// Pull the structured questions block (if any) off the tail of the
|
|
8913
|
-
// response. response_text is just the prose; questions ride alongside
|
|
8914
|
-
// on the stream_end event + the persisted assistant item.
|
|
8915
|
-
const { prose, questions } = extract_xuda_questions(raw_response_text);
|
|
8916
|
-
const response_text = prose || raw_response_text;
|
|
8917
11019
|
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
11020
|
+
const run_ret = await start_code_run({
|
|
11021
|
+
uid,
|
|
11022
|
+
profile_id,
|
|
11023
|
+
app_id: account_profile_info.app_id,
|
|
11024
|
+
target_app_id,
|
|
11025
|
+
conversation_id,
|
|
11026
|
+
conversation_doc,
|
|
11027
|
+
reference_id: conversation_doc.reference_id,
|
|
11028
|
+
job_id,
|
|
11029
|
+
prompt: codex_prompt,
|
|
11030
|
+
prompt_conversation_item_id,
|
|
11031
|
+
response_conversation_item_id,
|
|
11032
|
+
ip: vps_ip,
|
|
11033
|
+
app_obj: vibe_app_obj,
|
|
11034
|
+
attachments,
|
|
11035
|
+
codex_model: req.codex_model || req.ai_model,
|
|
11036
|
+
resume_session_id,
|
|
11037
|
+
// Deliberate: this build runs on the CUSTOMER'S OWN VPS over SSH and has to install
|
|
11038
|
+
// packages, write outside a workspace and restart services there, so it keeps full
|
|
11039
|
+
// access. Stated explicitly now that the global default is confined, so the choice
|
|
11040
|
+
// is visible instead of inherited. Nothing on Xuda infrastructure may set this.
|
|
11041
|
+
bypass_sandbox: true,
|
|
11042
|
+
...(agent_key ? { agent_mcp: { url: 'http://localhost:3012/mcp', bearer_env_var: 'XUDA_AGENT_KEY', bearer_value: agent_key } } : {}),
|
|
11043
|
+
// The ephemeral scoped key must outlive THIS function now (the run is still using it over
|
|
11044
|
+
// MCP), so the run revokes it when it finishes. A Full Stack VPS's persistent key is kept.
|
|
11045
|
+
...(agent_key && agent_key_ephemeral ? { revoke_api_key: agent_key } : {}),
|
|
11046
|
+
});
|
|
8921
11047
|
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
11048
|
+
if (run_ret.code < 0) {
|
|
11049
|
+
const failed_msg = "I couldn't start that just now. Please try again in a moment.";
|
|
11050
|
+
emitToDashboard('response_start');
|
|
11051
|
+
streamText(failed_msg);
|
|
11052
|
+
emitToDashboard('stream_end', undefined, { error: true });
|
|
11053
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
11054
|
+
conversation_doc.ts = Date.now();
|
|
11055
|
+
conversation_doc.stat = 3;
|
|
11056
|
+
conversation_doc.process_stat = 'partial';
|
|
11057
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
11058
|
+
return await saveAssistantItem(failed_msg, { is_request_error: true });
|
|
11059
|
+
}
|
|
8927
11060
|
|
|
8928
|
-
|
|
11061
|
+
// The conversation stays at stat 2 (running) on purpose: the run owns the ending. The
|
|
11062
|
+
// assistant item is written by the run too, so nothing is saved here.
|
|
11063
|
+
//
|
|
11064
|
+
// job_self_finalizes keeps http_module from closing the job when this request returns. The
|
|
11065
|
+
// job has to stay open for the whole run, because Stop aborts BY job id and the run watches
|
|
11066
|
+
// that same flag.
|
|
11067
|
+
return { code: 1, job_self_finalizes: true, data: { id: response_conversation_item_id, code_run_id: run_ret.data?.run_id, state: run_ret.data?.state } };
|
|
8929
11068
|
}
|
|
8930
11069
|
|
|
8931
11070
|
// ── AI plugin build / modify ───────────────────────────────────────────
|
|
@@ -10022,6 +12161,8 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
10022
12161
|
reference_id: conversation_doc.reference_id,
|
|
10023
12162
|
direction: 'out',
|
|
10024
12163
|
role: 'user',
|
|
12164
|
+
// My own prompt is read the moment I type it, same as the dashboard chat path does.
|
|
12165
|
+
read: { [uid]: Date.now() },
|
|
10025
12166
|
job_id,
|
|
10026
12167
|
metadata,
|
|
10027
12168
|
};
|
|
@@ -10920,6 +13061,20 @@ const add_conversation_item = async function (uid, profile_id, conversation_id,
|
|
|
10920
13061
|
},
|
|
10921
13062
|
];
|
|
10922
13063
|
|
|
13064
|
+
// Same story as the contact thread below: a conversation is not guaranteed to carry an
|
|
13065
|
+
// AI thread. create_conversation always mints one, but the ticket paths do not, either
|
|
13066
|
+
// because they never asked for one (ticket_module's mirror conversation) or because the
|
|
13067
|
+
// call failed and was swallowed (_contact_form_create_ticket). Minted here rather than
|
|
13068
|
+
// passed on as undefined, which the SDK turns into `/conversations/undefined/items`.
|
|
13069
|
+
if (!conversation_doc.reference_conversation_id) {
|
|
13070
|
+
const conversation_obj = await create_openai_conversation();
|
|
13071
|
+
conversation_doc.reference_conversation_id = conversation_obj.id;
|
|
13072
|
+
conversation_doc.ts = Date.now();
|
|
13073
|
+
const backfill_ret = await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
13074
|
+
conversation_doc._rev = backfill_ret?.rev || conversation_doc._rev;
|
|
13075
|
+
console.log(`[ai_module] opened a missing AI thread for conversation ${conversation_id}: ${conversation_obj.id}`);
|
|
13076
|
+
}
|
|
13077
|
+
|
|
10923
13078
|
let item;
|
|
10924
13079
|
try {
|
|
10925
13080
|
item = await client.conversations.items.create(conversation_doc.reference_conversation_id, {
|
|
@@ -10928,12 +13083,39 @@ const add_conversation_item = async function (uid, profile_id, conversation_id,
|
|
|
10928
13083
|
} catch (err) {
|
|
10929
13084
|
throw err;
|
|
10930
13085
|
}
|
|
13086
|
+
// The item is filed twice: once on this conversation, and once on the CONTACT's own
|
|
13087
|
+
// thread, which is what lets the contact card carry the whole history with that person
|
|
13088
|
+
// across every conversation they appear in.
|
|
13089
|
+
//
|
|
13090
|
+
// That contact thread is not guaranteed to exist. add_contact opens one only for a
|
|
13091
|
+
// contact it did NOT classify as spam (a spam contact gets no AI work spent on it at
|
|
13092
|
+
// all), and a contact created before the field existed has none either. Handing the
|
|
13093
|
+
// missing id straight to the API is what used to break the inbound mail pipeline: the
|
|
13094
|
+
// SDK builds `/conversations/undefined/items` and throws "Path parameters result in
|
|
13095
|
+
// path with invalid segments", which fails the whole email job, so the message never
|
|
13096
|
+
// finished processing and the mail client showed a red Error badge on it once the
|
|
13097
|
+
// attempts ran out (repro 2026-08-13, eml_bmaaaaae, sender b0001@xuda.app).
|
|
13098
|
+
//
|
|
13099
|
+
// So the thread is opened here on demand for a real contact, and a spam contact is
|
|
13100
|
+
// skipped rather than given one, which is exactly the rule add_contact applies. The
|
|
13101
|
+
// skip is not permanent: un-marking a contact as spam backfills the thread and
|
|
13102
|
+
// reprocesses their mail (account_module not_spam_contact).
|
|
10931
13103
|
if (conversation_doc.reference_type === 'contacts' && contact_id) {
|
|
10932
13104
|
try {
|
|
10933
13105
|
let contact_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, contact_id);
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
13106
|
+
if (contact_doc && !contact_doc.contact_reference_conversation_id && !contact_doc.is_spam) {
|
|
13107
|
+
const conversation_obj = await create_openai_conversation();
|
|
13108
|
+
contact_doc.contact_reference_conversation_id = conversation_obj.id;
|
|
13109
|
+
contact_doc.ts = Date.now();
|
|
13110
|
+
const backfill_ret = await db_module.save_app_couch_doc_native(account_profile_info.app_id, contact_doc);
|
|
13111
|
+
contact_doc._rev = backfill_ret?.rev || contact_doc._rev;
|
|
13112
|
+
console.log(`[ai_module] opened a missing AI thread for contact ${contact_id}: ${conversation_obj.id}`);
|
|
13113
|
+
}
|
|
13114
|
+
if (contact_doc?.contact_reference_conversation_id) {
|
|
13115
|
+
await client.conversations.items.create(contact_doc.contact_reference_conversation_id, {
|
|
13116
|
+
items,
|
|
13117
|
+
});
|
|
13118
|
+
}
|
|
10937
13119
|
} catch (err) {
|
|
10938
13120
|
throw err;
|
|
10939
13121
|
}
|
|
@@ -11228,6 +13410,8 @@ Return only the email body.`;
|
|
|
11228
13410
|
role: 'user',
|
|
11229
13411
|
auto_response: true,
|
|
11230
13412
|
widget_source: true,
|
|
13413
|
+
// Sent under this account, so it is not unread for this account.
|
|
13414
|
+
read: { [uid]: Date.now() },
|
|
11231
13415
|
rtl: _common.detectRTL(response_text),
|
|
11232
13416
|
};
|
|
11233
13417
|
await db_module.save_app_couch_doc(account_profile_info.app_id, reply_item);
|
|
@@ -12540,6 +14724,74 @@ export const modify_profile_avatar = async function (req, job_id, headers) {
|
|
|
12540
14724
|
}
|
|
12541
14725
|
};
|
|
12542
14726
|
|
|
14727
|
+
// What the Contacts tab is holding for you that is NOT a pending request: messages on
|
|
14728
|
+
// contacts you have not read yet, the same number the contact card shows on its own badge.
|
|
14729
|
+
// The tab used to count requests only, so a card sitting on a red 1 was invisible from
|
|
14730
|
+
// every other tab and the strip disagreed with the grid underneath it.
|
|
14731
|
+
//
|
|
14732
|
+
// Two view reads for the whole account, not two per contact: the counts view is grouped by
|
|
14733
|
+
// reference_id and the read view by [me, reference_id], so both come back as one row per
|
|
14734
|
+
// reference and the contact ids just pick the rows that belong to a contact (an agent or a
|
|
14735
|
+
// mini app is a reference too, and those are not this tab's business).
|
|
14736
|
+
const get_contacts_unread_total = async function (uid, account_profile_info) {
|
|
14737
|
+
try {
|
|
14738
|
+
const app_id = account_profile_info.app_id;
|
|
14739
|
+
const opt = {
|
|
14740
|
+
selector: { docType: 'contact', stat: { $lt: 4 } },
|
|
14741
|
+
fields: ['_id'],
|
|
14742
|
+
limit: 9999,
|
|
14743
|
+
};
|
|
14744
|
+
|
|
14745
|
+
// Same visibility rule the contacts grid uses, so the badge can never claim unread
|
|
14746
|
+
// messages sitting on a contact this profile is not allowed to see.
|
|
14747
|
+
if (!account_profile_info.is_main) {
|
|
14748
|
+
opt.selector.account_profiles = { $in: [account_profile_info.account_profile_id] };
|
|
14749
|
+
} else {
|
|
14750
|
+
const active_profiles = await db_module.find_app_couch_query(app_id, {
|
|
14751
|
+
selector: { docType: 'account_profile', uid: account_profile_info.uid, stat: { $lt: 4 } },
|
|
14752
|
+
fields: ['_id'],
|
|
14753
|
+
limit: 9999,
|
|
14754
|
+
});
|
|
14755
|
+
const active_profile_ids = (active_profiles.docs || []).map((profile) => profile._id).filter(Boolean);
|
|
14756
|
+
opt.selector.account_profiles = { $in: active_profile_ids.length ? active_profile_ids : [account_profile_info.account_profile_id] };
|
|
14757
|
+
}
|
|
14758
|
+
|
|
14759
|
+
const contacts_ret = await db_module.find_app_couch_query(app_id, opt);
|
|
14760
|
+
const contact_ids = new Set((contacts_ret?.docs || []).map((doc) => doc._id));
|
|
14761
|
+
if (!contact_ids.size) return 0;
|
|
14762
|
+
|
|
14763
|
+
const counts_ret = await db_module.get_app_couch_view(app_id, 'chat_conversation_item_counts', {
|
|
14764
|
+
reduce: true,
|
|
14765
|
+
group_level: 1,
|
|
14766
|
+
});
|
|
14767
|
+
const read_ret = await db_module.get_app_couch_view(app_id, 'chat_conversation_item_read', {
|
|
14768
|
+
reduce: true,
|
|
14769
|
+
group_level: 2,
|
|
14770
|
+
|
|
14771
|
+
startkey: [uid, ''],
|
|
14772
|
+
endkey: [uid, 'zzzzzz'],
|
|
14773
|
+
});
|
|
14774
|
+
|
|
14775
|
+
const read_by_contact = {};
|
|
14776
|
+
for (const row of read_ret?.rows || []) {
|
|
14777
|
+
read_by_contact[row.key?.[1]] = row.value;
|
|
14778
|
+
}
|
|
14779
|
+
|
|
14780
|
+
let total = 0;
|
|
14781
|
+
for (const row of counts_ret?.rows || []) {
|
|
14782
|
+
const contact_id = row.key?.[0];
|
|
14783
|
+
if (!contact_ids.has(contact_id)) continue;
|
|
14784
|
+
total += Math.max(0, (row.value || 0) - (read_by_contact[contact_id] || 0));
|
|
14785
|
+
}
|
|
14786
|
+
|
|
14787
|
+
return total;
|
|
14788
|
+
} catch (err) {
|
|
14789
|
+
// An account whose project is missing these views (they are added by login maintenance)
|
|
14790
|
+
// gets the request count alone rather than an error on the whole strip.
|
|
14791
|
+
return 0;
|
|
14792
|
+
}
|
|
14793
|
+
};
|
|
14794
|
+
|
|
12543
14795
|
export const get_ai_workspace_pending_counts = async function (req, job_id, headers) {
|
|
12544
14796
|
const { uid } = req;
|
|
12545
14797
|
const account_profile_info = await get_active_account_profile_info(uid);
|
|
@@ -12567,9 +14819,11 @@ export const get_ai_workspace_pending_counts = async function (req, job_id, head
|
|
|
12567
14819
|
data['apps'] = item.value;
|
|
12568
14820
|
break;
|
|
12569
14821
|
|
|
14822
|
+
// Two separate rows at group_level 999, so this has to add up rather than assign:
|
|
14823
|
+
// whichever landed second used to overwrite the first.
|
|
12570
14824
|
case 'contact_connection':
|
|
12571
14825
|
case 'contact':
|
|
12572
|
-
data['contacts'] = item.value;
|
|
14826
|
+
data['contacts'] = (data['contacts'] || 0) + item.value;
|
|
12573
14827
|
break;
|
|
12574
14828
|
|
|
12575
14829
|
case 'account_profile':
|
|
@@ -12585,6 +14839,20 @@ export const get_ai_workspace_pending_counts = async function (req, job_id, head
|
|
|
12585
14839
|
}
|
|
12586
14840
|
}
|
|
12587
14841
|
}
|
|
14842
|
+
|
|
14843
|
+
// Reported SEPARATELY from data.contacts, never folded into it. `contacts` is the
|
|
14844
|
+
// PENDING count and the dashboard puts it on the filter trigger and beside the
|
|
14845
|
+
// "Pending" filter option, where a number that also included unread messages would be
|
|
14846
|
+
// a lie about how many requests are waiting. The tab badge is the only thing that
|
|
14847
|
+
// wants the two added together, and it does that addition itself.
|
|
14848
|
+
//
|
|
14849
|
+
// Counted for every profile, not just the main one: the pending block above is
|
|
14850
|
+
// main-only because only the main profile answers connection requests.
|
|
14851
|
+
const contacts_unread = await get_contacts_unread_total(uid, account_profile_info);
|
|
14852
|
+
if (contacts_unread) {
|
|
14853
|
+
data['contacts_unread'] = contacts_unread;
|
|
14854
|
+
}
|
|
14855
|
+
|
|
12588
14856
|
return { code: 111, data };
|
|
12589
14857
|
} catch (err) {
|
|
12590
14858
|
return { code: -111, data: err.message };
|
|
@@ -12812,8 +15080,8 @@ const get_ai_chat_info = async function (uid, conversation_doc) {
|
|
|
12812
15080
|
const reference_doc = _.cloneDeep(doc);
|
|
12813
15081
|
doc = await db_module.get_app_couch_doc_native(reference_doc.shared_from_app_id, reference_doc.share_item_id);
|
|
12814
15082
|
doc.reference_doc = reference_doc;
|
|
12815
|
-
|
|
12816
|
-
doc
|
|
15083
|
+
// UI-221 / UI-222
|
|
15084
|
+
apply_recipient_marks(doc, reference_doc);
|
|
12817
15085
|
}
|
|
12818
15086
|
|
|
12819
15087
|
const reference_id = doc.reference_id || '';
|
|
@@ -12855,11 +15123,8 @@ const get_mini_app_info = async function (uid, prog_doc) {
|
|
|
12855
15123
|
const reference_doc = _.cloneDeep(doc);
|
|
12856
15124
|
doc = await db_module.get_app_couch_doc_native(reference_doc.studio_meta.shared_from_app_id, reference_doc.studio_meta.share_item_id);
|
|
12857
15125
|
doc.reference_doc = reference_doc;
|
|
12858
|
-
|
|
12859
|
-
doc
|
|
12860
|
-
...(doc.studio_meta || {}),
|
|
12861
|
-
pinned: reference_doc?.studio_meta?.pinned ?? doc?.studio_meta?.pinned,
|
|
12862
|
-
};
|
|
15126
|
+
// UI-221 / UI-222
|
|
15127
|
+
apply_recipient_marks(doc, reference_doc);
|
|
12863
15128
|
}
|
|
12864
15129
|
|
|
12865
15130
|
if (doc?.studio_meta?.createdByUid) {
|
|
@@ -13034,6 +15299,125 @@ export const unpin_ai_agent = async function (req, job_id, headers) {
|
|
|
13034
15299
|
}
|
|
13035
15300
|
};
|
|
13036
15301
|
|
|
15302
|
+
// ─── UI-221: favorites ────────────────────────────────────────────────────────────────
|
|
15303
|
+
// A second, softer mark than pin. Pin is about the WORKING SET (it puts the item in the
|
|
15304
|
+
// pinned bar, which holds a handful of things at a time); favorite is about what the user
|
|
15305
|
+
// keeps coming back to, and it only ever answers a filter. They are deliberately separate
|
|
15306
|
+
// flags: an item can be both, and un-pinning something you use every day should not lose
|
|
15307
|
+
// the fact that you like it.
|
|
15308
|
+
//
|
|
15309
|
+
// The flag lives where the pinned one lives for each kind, so the two read the same way in
|
|
15310
|
+
// a doc and in a Mango selector: `favorite` on a chat_conversation, `studio_meta.favorite`
|
|
15311
|
+
// on a studio doc (mini app / AI agent).
|
|
15312
|
+
const _set_ai_favorite = async function ({ doc_id, on_studio_meta, favorite, app_id }) {
|
|
15313
|
+
const doc = await db_module.get_app_couch_doc_native(app_id, doc_id);
|
|
15314
|
+
|
|
15315
|
+
if (on_studio_meta) {
|
|
15316
|
+
doc.studio_meta = doc.studio_meta || {};
|
|
15317
|
+
doc.studio_meta.favorite = favorite;
|
|
15318
|
+
} else {
|
|
15319
|
+
doc.favorite = favorite;
|
|
15320
|
+
}
|
|
15321
|
+
|
|
15322
|
+
return await db_module.save_app_couch_doc(app_id, doc);
|
|
15323
|
+
};
|
|
15324
|
+
|
|
15325
|
+
export const favorite_ai_chat = async function (req) {
|
|
15326
|
+
const { uid, conversation_id } = req;
|
|
15327
|
+
try {
|
|
15328
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
15329
|
+
const save_ret = await _set_ai_favorite({ doc_id: conversation_id, favorite: true, app_id: account_profile_info.app_id });
|
|
15330
|
+
|
|
15331
|
+
log_chat_activity(uid, conversation_id, 'favorited', { by: 'user' }, account_profile_info.app_id);
|
|
15332
|
+
|
|
15333
|
+
return save_ret;
|
|
15334
|
+
} catch (err) {
|
|
15335
|
+
return {
|
|
15336
|
+
code: -24,
|
|
15337
|
+
data: err.message,
|
|
15338
|
+
};
|
|
15339
|
+
}
|
|
15340
|
+
};
|
|
15341
|
+
|
|
15342
|
+
export const unfavorite_ai_chat = async function (req) {
|
|
15343
|
+
const { uid, conversation_id } = req;
|
|
15344
|
+
try {
|
|
15345
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
15346
|
+
const save_ret = await _set_ai_favorite({ doc_id: conversation_id, favorite: false, app_id: account_profile_info.app_id });
|
|
15347
|
+
|
|
15348
|
+
log_chat_activity(uid, conversation_id, 'unfavorited', { by: 'user' }, account_profile_info.app_id);
|
|
15349
|
+
|
|
15350
|
+
return save_ret;
|
|
15351
|
+
} catch (err) {
|
|
15352
|
+
return {
|
|
15353
|
+
code: -24,
|
|
15354
|
+
data: err.message,
|
|
15355
|
+
};
|
|
15356
|
+
}
|
|
15357
|
+
};
|
|
15358
|
+
|
|
15359
|
+
export const favorite_mini_app = async function (req) {
|
|
15360
|
+
const { uid, prog_id } = req;
|
|
15361
|
+
try {
|
|
15362
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
15363
|
+
return await _set_ai_favorite({ doc_id: prog_id, on_studio_meta: true, favorite: true, app_id: account_profile_info.app_id });
|
|
15364
|
+
} catch (err) {
|
|
15365
|
+
return {
|
|
15366
|
+
code: -24,
|
|
15367
|
+
data: err.message,
|
|
15368
|
+
};
|
|
15369
|
+
}
|
|
15370
|
+
};
|
|
15371
|
+
|
|
15372
|
+
export const unfavorite_mini_app = async function (req) {
|
|
15373
|
+
const { uid, prog_id } = req;
|
|
15374
|
+
try {
|
|
15375
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
15376
|
+
return await _set_ai_favorite({ doc_id: prog_id, on_studio_meta: true, favorite: false, app_id: account_profile_info.app_id });
|
|
15377
|
+
} catch (err) {
|
|
15378
|
+
return {
|
|
15379
|
+
code: -24,
|
|
15380
|
+
data: err.message,
|
|
15381
|
+
};
|
|
15382
|
+
}
|
|
15383
|
+
};
|
|
15384
|
+
|
|
15385
|
+
// Agents resolve their app the way pin_ai_agent does (the account's default project),
|
|
15386
|
+
// not through the active profile, because an agent doc is account-level.
|
|
15387
|
+
export const favorite_ai_agent = async function (req) {
|
|
15388
|
+
const { uid, agent_id } = req;
|
|
15389
|
+
try {
|
|
15390
|
+
const app_id = await get_account_default_project_id(uid);
|
|
15391
|
+
const save_ret = await _set_ai_favorite({ doc_id: agent_id, on_studio_meta: true, favorite: true, app_id });
|
|
15392
|
+
|
|
15393
|
+
log_agent_activity(uid, agent_id, 'favorited', { by: 'user' }, app_id);
|
|
15394
|
+
|
|
15395
|
+
return save_ret;
|
|
15396
|
+
} catch (err) {
|
|
15397
|
+
return {
|
|
15398
|
+
code: -24,
|
|
15399
|
+
data: err.message,
|
|
15400
|
+
};
|
|
15401
|
+
}
|
|
15402
|
+
};
|
|
15403
|
+
|
|
15404
|
+
export const unfavorite_ai_agent = async function (req) {
|
|
15405
|
+
const { uid, agent_id } = req;
|
|
15406
|
+
try {
|
|
15407
|
+
const app_id = await get_account_default_project_id(uid);
|
|
15408
|
+
const save_ret = await _set_ai_favorite({ doc_id: agent_id, on_studio_meta: true, favorite: false, app_id });
|
|
15409
|
+
|
|
15410
|
+
log_agent_activity(uid, agent_id, 'unfavorited', { by: 'user' }, app_id);
|
|
15411
|
+
|
|
15412
|
+
return save_ret;
|
|
15413
|
+
} catch (err) {
|
|
15414
|
+
return {
|
|
15415
|
+
code: -24,
|
|
15416
|
+
data: err.message,
|
|
15417
|
+
};
|
|
15418
|
+
}
|
|
15419
|
+
};
|
|
15420
|
+
|
|
13037
15421
|
export const get_transcript = async function (uid, app_id, conversation_id, account_profile_info, filename) {
|
|
13038
15422
|
async function streamToBuffer(fileStream) {
|
|
13039
15423
|
const chunks = [];
|
|
@@ -20277,6 +22661,8 @@ const contact_ticket_conversation = async function (req, job_id, headers) {
|
|
|
20277
22661
|
direction: 'out',
|
|
20278
22662
|
role: 'user',
|
|
20279
22663
|
auto_response: _auto_response || undefined,
|
|
22664
|
+
// The reply went out from this desk, so it is not waiting to be read here.
|
|
22665
|
+
read: { [uid]: Date.now() },
|
|
20280
22666
|
rtl: _common.detectRTL(body),
|
|
20281
22667
|
};
|
|
20282
22668
|
const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
|