@xuda.io/ai_module 1.1.5656 → 1.1.5658

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_ms.mjs CHANGED
@@ -69,10 +69,30 @@ export const git_repo_list = async function (...args) {
69
69
  return await broker.send_to_queue("git_repo_list", ...args);
70
70
  };
71
71
 
72
+ export const xudex_key_set = async function (...args) {
73
+ return await broker.send_to_queue("xudex_key_set", ...args);
74
+ };
75
+
76
+ export const xudex_key_list = async function (...args) {
77
+ return await broker.send_to_queue("xudex_key_list", ...args);
78
+ };
79
+
80
+ export const xudex_key_delete = async function (...args) {
81
+ return await broker.send_to_queue("xudex_key_delete", ...args);
82
+ };
83
+
84
+ export const xudex_engine_list = async function (...args) {
85
+ return await broker.send_to_queue("xudex_engine_list", ...args);
86
+ };
87
+
72
88
  export const git_repo_disconnect = async function (...args) {
73
89
  return await broker.send_to_queue("git_repo_disconnect", ...args);
74
90
  };
75
91
 
92
+ export const xudex_run = async function (...args) {
93
+ return await broker.send_to_queue("xudex_run", ...args);
94
+ };
95
+
76
96
  export const git_repo_status = async function (...args) {
77
97
  return await broker.send_to_queue("git_repo_status", ...args);
78
98
  };
@@ -113,6 +133,10 @@ export const unarchive_ai_chat = async function (...args) {
113
133
  return await broker.send_to_queue("unarchive_ai_chat", ...args);
114
134
  };
115
135
 
136
+ export const rename_ai_chat = async function (...args) {
137
+ return await broker.send_to_queue("rename_ai_chat", ...args);
138
+ };
139
+
116
140
  export const get_ai_agent_info = async function (...args) {
117
141
  return await broker.send_to_queue("get_ai_agent_info", ...args);
118
142
  };
@@ -153,6 +177,10 @@ export const delete_mini_app = async function (...args) {
153
177
  return await broker.send_to_queue("delete_mini_app", ...args);
154
178
  };
155
179
 
180
+ export const rename_mini_app = async function (...args) {
181
+ return await broker.send_to_queue("rename_mini_app", ...args);
182
+ };
183
+
156
184
  export const start_cli_agent_conversation = async function (...args) {
157
185
  return await broker.send_to_queue("start_cli_agent_conversation", ...args);
158
186
  };
@@ -289,6 +317,10 @@ export const create_mini_app = async function (...args) {
289
317
  return await broker.send_to_queue("create_mini_app", ...args);
290
318
  };
291
319
 
320
+ export const emit_mini_app_doc_updated = async function (...args) {
321
+ return await broker.send_to_queue("emit_mini_app_doc_updated", ...args);
322
+ };
323
+
292
324
  export const pin_ai_chat = async function (...args) {
293
325
  return await broker.send_to_queue("pin_ai_chat", ...args);
294
326
  };
package/index_msa.mjs CHANGED
@@ -69,10 +69,30 @@ export const git_repo_list = function (...args) {
69
69
  broker.send_to_queue_async("git_repo_list", ...args);
70
70
  };
71
71
 
72
+ export const xudex_key_set = function (...args) {
73
+ broker.send_to_queue_async("xudex_key_set", ...args);
74
+ };
75
+
76
+ export const xudex_key_list = function (...args) {
77
+ broker.send_to_queue_async("xudex_key_list", ...args);
78
+ };
79
+
80
+ export const xudex_key_delete = function (...args) {
81
+ broker.send_to_queue_async("xudex_key_delete", ...args);
82
+ };
83
+
84
+ export const xudex_engine_list = function (...args) {
85
+ broker.send_to_queue_async("xudex_engine_list", ...args);
86
+ };
87
+
72
88
  export const git_repo_disconnect = function (...args) {
73
89
  broker.send_to_queue_async("git_repo_disconnect", ...args);
74
90
  };
75
91
 
92
+ export const xudex_run = function (...args) {
93
+ broker.send_to_queue_async("xudex_run", ...args);
94
+ };
95
+
76
96
  export const git_repo_status = function (...args) {
77
97
  broker.send_to_queue_async("git_repo_status", ...args);
78
98
  };
@@ -113,6 +133,10 @@ export const unarchive_ai_chat = function (...args) {
113
133
  broker.send_to_queue_async("unarchive_ai_chat", ...args);
114
134
  };
115
135
 
136
+ export const rename_ai_chat = function (...args) {
137
+ broker.send_to_queue_async("rename_ai_chat", ...args);
138
+ };
139
+
116
140
  export const get_ai_agent_info = function (...args) {
117
141
  broker.send_to_queue_async("get_ai_agent_info", ...args);
118
142
  };
@@ -153,6 +177,10 @@ export const delete_mini_app = function (...args) {
153
177
  broker.send_to_queue_async("delete_mini_app", ...args);
154
178
  };
155
179
 
180
+ export const rename_mini_app = function (...args) {
181
+ broker.send_to_queue_async("rename_mini_app", ...args);
182
+ };
183
+
156
184
  export const start_cli_agent_conversation = function (...args) {
157
185
  broker.send_to_queue_async("start_cli_agent_conversation", ...args);
158
186
  };
@@ -289,6 +317,10 @@ export const create_mini_app = function (...args) {
289
317
  broker.send_to_queue_async("create_mini_app", ...args);
290
318
  };
291
319
 
320
+ export const emit_mini_app_doc_updated = function (...args) {
321
+ broker.send_to_queue_async("emit_mini_app_doc_updated", ...args);
322
+ };
323
+
292
324
  export const pin_ai_chat = function (...args) {
293
325
  broker.send_to_queue_async("pin_ai_chat", ...args);
294
326
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5656",
3
+ "version": "1.1.5658",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",
@@ -18,7 +18,7 @@
18
18
  "openai": "^6.7.0",
19
19
  "puppeteer-extra": "^3.3.6",
20
20
  "puppeteer-extra-plugin-stealth": "^2.11.2",
21
- "sharp": "^0.34.4",
21
+ "sharp": "^0.35.0",
22
22
  "uglify-js": "^3.19.3",
23
23
  "utf8": "^3.0.0",
24
24
  "vm2": "^3.10.0",
@@ -0,0 +1,323 @@
1
+ // Xudex engines: the seam between "an agent did some work" and "which CLI did it".
2
+ //
3
+ // docs/plans/xudex.md 5.1. An engine is four things and no more:
4
+ //
5
+ // 1. AUTH what environment it needs to be allowed to run
6
+ // 2. A LAUNCH the argv, including how to resume an existing thread
7
+ // 3. A THREAD HANDLE the id that makes turn 2 a continuation instead of a cold start
8
+ // 4. A PARSER its streaming output, mapped onto one vocabulary
9
+ //
10
+ // Everything else is already engine-agnostic, and that is not luck: UI-226 deliberately
11
+ // made file capture REPOSITORY-based (`git status` minus what was dirty at the start,
12
+ // `git diff HEAD`) rather than asking the agent what it changed. So diffs, revert, the
13
+ // file list, commit, push and the PR all work identically whatever ran, and adding an
14
+ // engine cannot break them. That decision is why multi-engine costs a parser here instead
15
+ // of a rewrite everywhere.
16
+ //
17
+ // ── The normalized vocabulary ──────────────────────────────────────────────────────────
18
+ // Every adapter maps its own output onto these, and nothing above this file may branch on
19
+ // the engine. If a caller ever needs to ask "was this codex", the abstraction has failed.
20
+ //
21
+ // { type: 'session', session_id } the thread handle, for resuming
22
+ // { type: 'phase', text } what to show while it works
23
+ // { type: 'command', command, stage } a shell command it ran
24
+ // { type: 'message', text } words for the customer
25
+ // { type: 'usage', input_tokens, output_tokens }
26
+ // { type: 'error', message }
27
+ // { type: 'done', ok }
28
+ //
29
+ // ── Verification status, stated plainly ────────────────────────────────────────────────
30
+ // The CODEX adapter mirrors the argv and event handling already proven in this module
31
+ // (`exec --json`, `exec resume`, `thread.started`, `item.completed`), so it is a
32
+ // restatement of working code. The CLAUDE CODE adapter is written to that CLI's documented
33
+ // contract and is NOT yet verified against the real binary: it is not installed on dev and
34
+ // there is no BYOK key to run it with. Its flags and event names therefore live in config
35
+ // so a mismatch is a config fix rather than a code change, and it must be exercised
36
+ // against the real CLI before anyone is told it works.
37
+
38
+ import path from 'node:path';
39
+
40
+ if (!global._conf) {
41
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
42
+ }
43
+
44
+ const engines_conf = () => global._conf.xudex?.engines || {};
45
+
46
+ const num = (...values) => {
47
+ for (const v of values) {
48
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
49
+ if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
50
+ }
51
+ return 0;
52
+ };
53
+
54
+ // Token counts arrive under a dozen different spellings depending on the engine and its
55
+ // version, so normalizing them is worth doing once here rather than at every call site.
56
+ // This mirrors normalize_codex_usage in index.mjs, widened for the other providers.
57
+ export const normalize_usage = function (usage) {
58
+ if (!usage || typeof usage !== 'object') return { input_tokens: 0, output_tokens: 0 };
59
+ return {
60
+ input_tokens: num(
61
+ usage.input_tokens,
62
+ usage.inputTokens,
63
+ usage.prompt_tokens,
64
+ usage.promptTokens,
65
+ usage.total_input_tokens,
66
+ usage.total_token_usage?.input_tokens,
67
+ usage.totalTokenUsage?.inputTokens,
68
+ ),
69
+ output_tokens: num(
70
+ usage.output_tokens,
71
+ usage.outputTokens,
72
+ usage.completion_tokens,
73
+ usage.completionTokens,
74
+ usage.total_output_tokens,
75
+ usage.total_token_usage?.output_tokens,
76
+ usage.totalTokenUsage?.outputTokens,
77
+ ),
78
+ };
79
+ };
80
+
81
+ // Both engines speak JSON lines, so the line splitter is shared. It keeps a partial tail
82
+ // between chunks, because a stream chunk boundary lands mid-line often enough that not
83
+ // handling it looks like random missing events.
84
+ export const create_line_parser = function (on_object) {
85
+ let buffer = '';
86
+ return function (chunk) {
87
+ buffer += chunk;
88
+ const lines = buffer.split(/\r?\n/);
89
+ buffer = lines.pop() || '';
90
+ for (const line of lines) {
91
+ if (!line.trim()) continue;
92
+ try {
93
+ on_object(JSON.parse(line));
94
+ } catch (err) {
95
+ // A non-JSON line is the CLI talking to a human (a warning, a banner). It is not an
96
+ // error and it is not an event; dropping it is correct.
97
+ }
98
+ }
99
+ };
100
+ };
101
+
102
+ // ── Codex ──────────────────────────────────────────────────────────────────────────────
103
+ const codex_adapter = {
104
+ key: 'codex',
105
+ label: 'Codex',
106
+ provider: 'openai',
107
+ byok: false,
108
+
109
+ // `exec resume [OPTIONS] <SESSION_ID> [PROMPT]`: the id and the prompt are POSITIONAL and
110
+ // go last, and `--sandbox` is rejected outright on a resume because a resumed session
111
+ // keeps the sandbox it was created with. Both were verified against the CLI (0.128.0)
112
+ // when the vibe path was built; this is a restatement of that, not a new claim.
113
+ build_args({ model, resume_session_id, sandbox, bypass_sandbox } = {}) {
114
+ const resuming = !!resume_session_id;
115
+ const args = resuming ? ['exec', 'resume', '--json'] : ['exec', '--json'];
116
+ if (model) args.push('--model', model);
117
+ if (bypass_sandbox === true) {
118
+ args.push('--dangerously-bypass-approvals-and-sandbox');
119
+ } else if (sandbox && !resuming) {
120
+ // Without --skip-git-repo-check a sandboxed run refuses to start outside a git repo.
121
+ args.push('--sandbox', sandbox, '--skip-git-repo-check');
122
+ } else if (sandbox) {
123
+ args.push('--skip-git-repo-check');
124
+ }
125
+ if (resuming) args.push(resume_session_id);
126
+ return args;
127
+ },
128
+
129
+ env({ api_key } = {}) {
130
+ // Codex runs on OUR key by default, which is what makes the free tier usable before a
131
+ // customer has any key of their own. A BYOK key simply overrides it.
132
+ return api_key ? { OPENAI_API_KEY: api_key } : {};
133
+ },
134
+
135
+ normalize(event) {
136
+ if (!event || typeof event !== 'object') return [];
137
+ switch (event.type) {
138
+ case 'thread.started':
139
+ return event.thread_id ? [{ type: 'session', session_id: event.thread_id }, { type: 'phase', text: 'Starting Codex thread' }] : [];
140
+ case 'turn.started':
141
+ return [{ type: 'phase', text: 'Working through the request' }];
142
+ case 'item.started': {
143
+ const item = event.item || {};
144
+ return item.type === 'command_execution' ? [{ type: 'command', command: item.command || '', stage: 'start' }] : [];
145
+ }
146
+ case 'item.completed': {
147
+ const item = event.item || {};
148
+ if (item.type === 'agent_message' && item.text) return [{ type: 'message', text: item.text }];
149
+ if (item.type === 'command_execution') return [{ type: 'command', command: item.command || '', stage: 'end', exit_code: item.exit_code ?? null }];
150
+ return [];
151
+ }
152
+ case 'turn.completed': {
153
+ const out = [];
154
+ if (event.usage) out.push({ type: 'usage', ...normalize_usage(event.usage) });
155
+ out.push({ type: 'done', ok: true });
156
+ return out;
157
+ }
158
+ case 'turn.failed':
159
+ case 'error':
160
+ return [{ type: 'error', message: event.message || event.error?.message || 'the engine reported an error' }];
161
+ default:
162
+ return [];
163
+ }
164
+ },
165
+ };
166
+
167
+ // ── Claude Code ────────────────────────────────────────────────────────────────────────
168
+ // BYOK only, deliberately. We do not front Anthropic tokens for customers, so a run without
169
+ // a key is refused before it starts rather than failing halfway with a provider error.
170
+ const claude_code_adapter = {
171
+ key: 'claude_code',
172
+ label: 'Claude Code',
173
+ provider: 'anthropic',
174
+ byok: true,
175
+
176
+ build_args({ model, resume_session_id } = {}) {
177
+ // `-p` is the non-interactive print mode; stream-json is what makes progress visible
178
+ // rather than arriving in one lump at the end.
179
+ const args = ['-p', '--output-format', 'stream-json', '--verbose'];
180
+ if (model) args.push('--model', model);
181
+ if (resume_session_id) args.push('--resume', resume_session_id);
182
+ // The workspace is already the isolation boundary (plan 3.1): the machine exists so
183
+ // untrusted code can run somewhere that is not our region server. A second permission
184
+ // prompt inside it would just hang a non-interactive run forever.
185
+ args.push('--dangerously-skip-permissions');
186
+ return args;
187
+ },
188
+
189
+ env({ api_key } = {}) {
190
+ return api_key ? { ANTHROPIC_API_KEY: api_key } : {};
191
+ },
192
+
193
+ normalize(event) {
194
+ if (!event || typeof event !== 'object') return [];
195
+ switch (event.type) {
196
+ case 'system':
197
+ return event.session_id ? [{ type: 'session', session_id: event.session_id }, { type: 'phase', text: 'Starting Claude Code session' }] : [];
198
+ case 'assistant': {
199
+ const out = [];
200
+ for (const block of event.message?.content || []) {
201
+ if (block.type === 'text' && block.text) out.push({ type: 'message', text: block.text });
202
+ // Claude Code reports a shell call as a tool_use block rather than its own event
203
+ // type, which is the one real shape difference from codex.
204
+ else if (block.type === 'tool_use') {
205
+ out.push({ type: 'command', command: block.input?.command || block.name || '', stage: 'start' });
206
+ }
207
+ }
208
+ if (event.message?.usage) out.push({ type: 'usage', ...normalize_usage(event.message.usage) });
209
+ return out;
210
+ }
211
+ case 'result': {
212
+ const out = [];
213
+ if (event.session_id) out.push({ type: 'session', session_id: event.session_id });
214
+ if (event.usage) out.push({ type: 'usage', ...normalize_usage(event.usage) });
215
+ if (event.is_error || event.subtype === 'error') {
216
+ out.push({ type: 'error', message: event.result || event.error || 'the engine reported an error' });
217
+ } else {
218
+ out.push({ type: 'done', ok: true });
219
+ }
220
+ return out;
221
+ }
222
+ default:
223
+ return [];
224
+ }
225
+ },
226
+ };
227
+
228
+ const ADAPTERS = { codex: codex_adapter, claude_code: claude_code_adapter };
229
+
230
+ // The registry. Config decides which engines a box offers and what binary each one is, so a
231
+ // third engine is a config entry plus one adapter object, which is what plan 5.1 promised.
232
+ export const list_engines = function () {
233
+ const conf = engines_conf();
234
+ return Object.keys(conf)
235
+ .filter((k) => ADAPTERS[k])
236
+ .map((k) => ({
237
+ key: k,
238
+ label: conf[k].label || ADAPTERS[k].label,
239
+ provider: ADAPTERS[k].provider,
240
+ byok: ADAPTERS[k].byok,
241
+ is_default: conf[k].default === true,
242
+ }));
243
+ };
244
+
245
+ export const get_engine = function (key) {
246
+ const conf = engines_conf();
247
+ const engine_key = key && conf[key] && ADAPTERS[key] ? key : Object.keys(conf).find((k) => conf[k].default === true && ADAPTERS[k]);
248
+ if (!engine_key) return null;
249
+ return { ...ADAPTERS[engine_key], command: conf[engine_key].command || engine_key, config: conf[engine_key] };
250
+ };
251
+
252
+ // Everything needed to actually start a run, in one object, so the caller does not assemble
253
+ // argv by hand and cannot forget the key.
254
+ //
255
+ // A BYOK engine with no key is refused HERE, before anything is spawned. The alternative is
256
+ // a run that starts, burns a workspace slot, and dies on a provider 401 that reads to the
257
+ // customer like our failure.
258
+ export const prepare_launch = function ({ engine: engine_key, model, resume_session_id, api_key, sandbox, bypass_sandbox } = {}) {
259
+ const engine = get_engine(engine_key);
260
+ if (!engine) return { error: 'That engine is not available.' };
261
+ if (engine.byok && !api_key) {
262
+ return {
263
+ error: `${engine.label} runs on your own API key. Add an ${engine.provider === 'anthropic' ? 'Anthropic' : engine.provider} API key in settings, then try again.`,
264
+ needs_key: engine.provider,
265
+ };
266
+ }
267
+ return {
268
+ engine: engine.key,
269
+ label: engine.label,
270
+ command: engine.command,
271
+ args: engine.build_args({ model, resume_session_id, sandbox, bypass_sandbox }),
272
+ env: engine.env({ api_key }),
273
+ resuming: !!resume_session_id,
274
+ };
275
+ };
276
+
277
+ // One stream, normalized. The caller gets our vocabulary and never sees the engine's.
278
+ export const create_stream = function (engine_key, on_event) {
279
+ const engine = get_engine(engine_key);
280
+ if (!engine) return () => {};
281
+ return create_line_parser((raw) => {
282
+ for (const normalized of engine.normalize(raw)) on_event(normalized);
283
+ });
284
+ };
285
+
286
+ // Fold a normalized stream into the few things a run has to remember. Kept separate from
287
+ // the parser so a caller can stream for the UI and summarize for the record with one pass.
288
+ export const create_run_state = function () {
289
+ const state = { session_id: null, messages: [], commands: [], usage: { input_tokens: 0, output_tokens: 0 }, error: null, done: false };
290
+ return {
291
+ state,
292
+ accept(event) {
293
+ switch (event.type) {
294
+ case 'session':
295
+ if (!state.session_id) state.session_id = event.session_id;
296
+ break;
297
+ case 'message':
298
+ state.messages.push(event.text);
299
+ break;
300
+ case 'command':
301
+ if (event.stage === 'start') state.commands.push(event.command);
302
+ break;
303
+ case 'usage':
304
+ // Engines report usage cumulatively in some versions and per-turn in others, so
305
+ // take the larger rather than summing: summing double-counts a cumulative report,
306
+ // and that error grows with the length of the conversation.
307
+ state.usage.input_tokens = Math.max(state.usage.input_tokens, event.input_tokens || 0);
308
+ state.usage.output_tokens = Math.max(state.usage.output_tokens, event.output_tokens || 0);
309
+ break;
310
+ case 'error':
311
+ // Verbatim, per plan 5.2. With BYOK we cannot see the customer's balance or rate
312
+ // limits, so a 429 from their account must read as their provider saying no, not
313
+ // as Xudex breaking.
314
+ state.error = event.message;
315
+ break;
316
+ case 'done':
317
+ state.done = true;
318
+ break;
319
+ }
320
+ return state;
321
+ },
322
+ };
323
+ };
@@ -0,0 +1,200 @@
1
+ // Xudex git mirror: how a repository reaches a machine we do not fully trust.
2
+ //
3
+ // docs/plans/xudex.md 9.2. UI-226's verified property is that a repository token never leaves the
4
+ // region server: it is validated by an authenticated ls-remote before anything is stored, kept on a
5
+ // git_repo doc in the account's project database rather than in xuda_master (which replicates
6
+ // fleet-wide), and handed to git only through GIT_ASKPASS so it never lands in an argv, in
7
+ // .git/config, or in the run log the customer sees.
8
+ //
9
+ // Moving the working copy onto a customer machine would break exactly that, because the machine would
10
+ // then need the token to fetch and to push. So it does not get one. The region server keeps a BARE
11
+ // MIRROR per repository and is the only thing that ever authenticates upstream:
12
+ //
13
+ // upstream <--(token, here)--> mirror <--(no credentials)--> workspace
14
+ //
15
+ // A push travels workspace -> mirror -> upstream, and only the last leg is authenticated.
16
+ //
17
+ // The mirror pays for itself twice: it is also a warm local cache, so a second clone is a local copy
18
+ // instead of a network fetch. That matters more than it looks. A reclaimed free account (plan 3.4)
19
+ // re-clones every project on its next message and CI clones onto an ephemeral workspace, so without
20
+ // the mirror every one of those is a full fetch against the provider from one address, which is slow
21
+ // and a good way to get our own address rate limited by GitHub.
22
+ //
23
+ // ── One deliberate non-choice ──────────────────────────────────────────────────────────────────
24
+ // This is NOT `git clone --mirror`, despite the name. That sets `remote.origin.mirror`, which makes a
25
+ // later `git push` mean "make the remote look exactly like me", including DELETING upstream branches
26
+ // this copy has not heard about yet. A bare clone with an explicit fetch refspec stores the same
27
+ // content with none of that reach, and every push here names its refspec rather than relying on a
28
+ // default.
29
+
30
+ import fs from 'fs';
31
+ import path from 'node:path';
32
+
33
+ const DEFAULTS = {
34
+ clone_timeout_ms: 10 * 60 * 1000,
35
+ clone_max_mb: 2048,
36
+ };
37
+
38
+ // `git_exec` is injected rather than imported: it lives in index.mjs, it is the thing that knows how
39
+ // to hand a token to git without it touching an argv, and a second copy of that is the last thing
40
+ // this codebase needs. `du_mb` likewise wraps the module's own process spawner.
41
+ export const create_mirror = function ({ git_exec, du_mb, mirror_root, clone_timeout_ms, clone_max_mb }) {
42
+ if (typeof git_exec !== 'function') throw new Error('create_mirror needs git_exec');
43
+ if (typeof mirror_root !== 'function') throw new Error('create_mirror needs a mirror_root resolver');
44
+ const CLONE_TIMEOUT_MS = clone_timeout_ms || DEFAULTS.clone_timeout_ms;
45
+ const CLONE_MAX_MB = clone_max_mb || DEFAULTS.clone_max_mb;
46
+
47
+ const mirror_dir = (uid, repo_id) => path.join(mirror_root(), String(uid), `${repo_id}.git`);
48
+
49
+ const exists = async (dir) =>
50
+ await fs.promises
51
+ .stat(path.join(dir, 'HEAD'))
52
+ .then(() => true)
53
+ .catch(() => false);
54
+
55
+ return {
56
+ mirror_dir,
57
+
58
+ // Create the mirror on first use, refresh it after that. This is the only function that reads
59
+ // from the customer's provider, so it is the only one that needs their token to do it.
60
+ async ensure({ uid, repo }) {
61
+ const dir = mirror_dir(uid, repo._id);
62
+ const token = repo.token || null;
63
+ await fs.promises.mkdir(path.dirname(dir), { recursive: true });
64
+
65
+ if (!(await exists(dir))) {
66
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
67
+ const cloned = await git_exec({ args: ['clone', '--bare', '--no-tags', repo.remote_url, dir], token, timeout_ms: CLONE_TIMEOUT_MS });
68
+ if (cloned.exit_code !== 0) {
69
+ console.error(`[xudex] mirror clone failed for ${repo._id}: exit ${cloned.exit_code}`);
70
+ return { error: 'Xuda could not clone that repository.' };
71
+ }
72
+ // A bare clone carries no fetch refspec, so without this the mirror would never learn about a
73
+ // new branch again. Set once, at creation, rather than on every refresh.
74
+ await git_exec({ cwd: dir, args: ['config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*'] });
75
+ const size_mb = du_mb ? await du_mb(dir) : 0;
76
+ if (size_mb > CLONE_MAX_MB) {
77
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
78
+ return { error: `That repository is ${size_mb} MB, which is over the ${CLONE_MAX_MB} MB limit.` };
79
+ }
80
+ return { dir, created: true };
81
+ }
82
+
83
+ const fetched = await git_exec({ cwd: dir, args: ['fetch', '--prune', 'origin'], token, timeout_ms: CLONE_TIMEOUT_MS });
84
+ // A stale mirror still serves a clone, so a failed refresh is a warning rather than an error:
85
+ // the customer gets slightly old code instead of no code, and the next run tries again.
86
+ if (fetched.exit_code !== 0) console.warn(`[xudex] mirror fetch failed for ${repo._id}: exit ${fetched.exit_code}`);
87
+ return { dir, created: false, stale: fetched.exit_code !== 0 };
88
+ },
89
+
90
+ // Clone a workspace from the mirror. No token is passed and none is needed, which is the whole
91
+ // point: this is the call that runs against the machine we trust least.
92
+ async clone_workspace({ mirror_dir: from, dest, branch, default_branch }) {
93
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
94
+ await fs.promises.rm(dest, { recursive: true, force: true }).catch(() => {});
95
+ const cloned = await git_exec({ args: ['clone', '--no-tags', from, dest], timeout_ms: CLONE_TIMEOUT_MS });
96
+ if (cloned.exit_code !== 0) {
97
+ console.error(`[xudex] workspace clone from mirror failed: exit ${cloned.exit_code}`);
98
+ return { error: 'Xuda could not prepare a working copy for this project.' };
99
+ }
100
+
101
+ if (branch) {
102
+ // Same branch contract as the Code tab: a chat owns a branch and the default branch is never
103
+ // checked out for writing, so nothing a run does can land on main without a person merging it.
104
+ const on_mirror = await git_exec({ cwd: dest, args: ['rev-parse', '--verify', `refs/remotes/origin/${branch}`] });
105
+ const from_ref = on_mirror.exit_code === 0 ? `origin/${branch}` : `origin/${default_branch}`;
106
+ const created = await git_exec({ cwd: dest, args: ['checkout', '-B', branch, from_ref] });
107
+ if (created.exit_code !== 0) {
108
+ // A repository with no commits yet has no origin/<default> to branch from.
109
+ const fallback = await git_exec({ cwd: dest, args: ['checkout', '-B', branch] });
110
+ if (fallback.exit_code !== 0) return { error: 'Xuda could not create a branch for this project.' };
111
+ }
112
+ }
113
+
114
+ const head = await git_exec({ cwd: dest, args: ['rev-parse', 'HEAD'] });
115
+ return { dir: dest, branch: branch || null, base_sha: head.exit_code === 0 ? head.stdout : null };
116
+ },
117
+
118
+ // Clone if the workspace is new, refresh it if it is not. A persistent machine keeps its
119
+ // working copy between runs, and that warmth is most of what the paid tiers are selling, so
120
+ // re-cloning every run would throw away the thing being paid for (and any uncommitted work
121
+ // sitting in the tree). Only a workspace that does not exist yet is cloned.
122
+ async sync_workspace({ mirror_dir: from, dest, branch, default_branch }) {
123
+ const has_git = await fs.promises
124
+ .stat(path.join(dest, '.git'))
125
+ .then(() => true)
126
+ .catch(() => false);
127
+
128
+ if (!has_git) return await this.clone_workspace({ mirror_dir: from, dest, branch, default_branch });
129
+
130
+ const fetched = await git_exec({ cwd: dest, args: ['fetch', '--prune', 'origin'], timeout_ms: CLONE_TIMEOUT_MS });
131
+ if (fetched.exit_code !== 0) console.warn(`[xudex] workspace fetch from mirror failed: exit ${fetched.exit_code}`);
132
+
133
+ if (branch) {
134
+ // Reuse the branch when it is already here rather than resetting onto the remote: a
135
+ // checkout that discarded uncommitted work would silently delete whatever the last run
136
+ // produced and the customer has not committed yet.
137
+ const local = await git_exec({ cwd: dest, args: ['rev-parse', '--verify', `refs/heads/${branch}`] });
138
+ if (local.exit_code === 0) {
139
+ const co = await git_exec({ cwd: dest, args: ['checkout', branch] });
140
+ if (co.exit_code !== 0) return { error: 'Xuda could not switch to this chat branch. There may be uncommitted work in the way.' };
141
+ } else {
142
+ const on_mirror = await git_exec({ cwd: dest, args: ['rev-parse', '--verify', `refs/remotes/origin/${branch}`] });
143
+ const from_ref = on_mirror.exit_code === 0 ? `origin/${branch}` : `origin/${default_branch}`;
144
+ const created = await git_exec({ cwd: dest, args: ['checkout', '-B', branch, from_ref] });
145
+ if (created.exit_code !== 0) {
146
+ const fallback = await git_exec({ cwd: dest, args: ['checkout', '-B', branch] });
147
+ if (fallback.exit_code !== 0) return { error: 'Xuda could not create a branch for this project.' };
148
+ }
149
+ }
150
+ }
151
+
152
+ const head = await git_exec({ cwd: dest, args: ['rev-parse', 'HEAD'] });
153
+ return { dir: dest, branch: branch || null, base_sha: head.exit_code === 0 ? head.stdout : null, reused: true };
154
+ },
155
+
156
+ // Workspace to mirror. Runs with the workspace's own git, over a path or a VPN url, and never
157
+ // sees a credential.
158
+ async push_from_workspace({ work_dir, mirror_dir: to, branch }) {
159
+ if (!branch) return { error: 'no branch to push' };
160
+ const pushed = await git_exec({ cwd: work_dir, args: ['push', to, `refs/heads/${branch}:refs/heads/${branch}`], timeout_ms: CLONE_TIMEOUT_MS });
161
+ if (pushed.exit_code !== 0) {
162
+ console.error(`[xudex] workspace push to mirror failed for ${branch}: exit ${pushed.exit_code}`);
163
+ return { error: 'Xuda could not save that branch.' };
164
+ }
165
+ return { pushed: true, branch };
166
+ },
167
+
168
+ // Mirror to upstream: the authenticated leg, and the only one.
169
+ //
170
+ // The default branch is refused here as well as in git_push. That is not redundant. git_push
171
+ // guards the button a person clicks; this guards the path a machine drives, and the machine is
172
+ // precisely the part the mirror exists to distrust.
173
+ async push_upstream({ uid, repo, branch }) {
174
+ if (!branch) return { error: 'no branch to push' };
175
+ if (branch === repo.default_branch) {
176
+ return { error: `Xuda does not push ${repo.default_branch} for you. Open a pull request instead.` };
177
+ }
178
+ const dir = mirror_dir(uid, repo._id);
179
+ if (!(await exists(dir))) return { error: 'this repository has no mirror yet' };
180
+ const pushed = await git_exec({
181
+ cwd: dir,
182
+ args: ['push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`],
183
+ token: repo.token || null,
184
+ timeout_ms: CLONE_TIMEOUT_MS,
185
+ });
186
+ if (pushed.exit_code !== 0) {
187
+ console.error(`[xudex] mirror push failed for ${repo._id} ${branch}: exit ${pushed.exit_code}`);
188
+ return { error: 'Xuda could not push that branch to your repository.' };
189
+ }
190
+ return { pushed: true, branch };
191
+ },
192
+
193
+ // Forget a repository entirely. Called on disconnect, beside the working copy removal that
194
+ // git_repo_disconnect already does.
195
+ async forget({ uid, repo_id }) {
196
+ await fs.promises.rm(mirror_dir(uid, repo_id), { recursive: true, force: true }).catch(() => {});
197
+ return true;
198
+ },
199
+ };
200
+ };