@xuda.io/ai_module 1.1.5657 → 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.mjs +1294 -41
- package/index_ms.mjs +32 -0
- package/index_msa.mjs +32 -0
- package/package.json +1 -1
- package/xudex_engines.mjs +323 -0
- package/xudex_mirror.mjs +200 -0
- package/xudex_preview.mjs +263 -0
- package/xudex_run.mjs +231 -0
- package/xudex_runtime.mjs +200 -0
- package/xudex_tracker.mjs +276 -0
- package/xudex_verify.mjs +272 -0
- package/xudex_vm.mjs +218 -0
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
|
@@ -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
|
+
};
|
package/xudex_mirror.mjs
ADDED
|
@@ -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
|
+
};
|