@botlearn-course/daemon 0.0.1
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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/agent-service-client.d.ts +18 -0
- package/dist/agent-service-client.js +108 -0
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +106 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +354 -0
- package/dist/course-client.d.ts +46 -0
- package/dist/course-client.js +143 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +85 -0
- package/dist/file-candidates.d.ts +34 -0
- package/dist/file-candidates.js +173 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +154 -0
- package/dist/path-env.d.ts +8 -0
- package/dist/path-env.js +42 -0
- package/dist/redaction.d.ts +24 -0
- package/dist/redaction.js +158 -0
- package/dist/run-dispatcher.d.ts +43 -0
- package/dist/run-dispatcher.js +294 -0
- package/dist/run-queue.d.ts +11 -0
- package/dist/run-queue.js +26 -0
- package/dist/runtime-capabilities.d.ts +3 -0
- package/dist/runtime-capabilities.js +42 -0
- package/dist/runtime-profile.d.ts +8 -0
- package/dist/runtime-profile.js +213 -0
- package/dist/runtimes/acp-stream.d.ts +96 -0
- package/dist/runtimes/acp-stream.js +488 -0
- package/dist/runtimes/claude-code.d.ts +41 -0
- package/dist/runtimes/claude-code.js +353 -0
- package/dist/runtimes/codex.d.ts +44 -0
- package/dist/runtimes/codex.js +332 -0
- package/dist/runtimes/deepseek-tui.d.ts +50 -0
- package/dist/runtimes/deepseek-tui.js +701 -0
- package/dist/runtimes/engine.d.ts +52 -0
- package/dist/runtimes/engine.js +127 -0
- package/dist/runtimes/fake.d.ts +13 -0
- package/dist/runtimes/fake.js +45 -0
- package/dist/runtimes/gemini.d.ts +39 -0
- package/dist/runtimes/gemini.js +251 -0
- package/dist/runtimes/hermes-agent.d.ts +61 -0
- package/dist/runtimes/hermes-agent.js +173 -0
- package/dist/runtimes/index.d.ts +15 -0
- package/dist/runtimes/index.js +74 -0
- package/dist/runtimes/kimi.d.ts +35 -0
- package/dist/runtimes/kimi.js +335 -0
- package/dist/runtimes/ndjson-stream.d.ts +51 -0
- package/dist/runtimes/ndjson-stream.js +207 -0
- package/dist/runtimes/openclaw-acp.d.ts +52 -0
- package/dist/runtimes/openclaw-acp.js +872 -0
- package/dist/runtimes/probe.d.ts +17 -0
- package/dist/runtimes/probe.js +54 -0
- package/dist/runtimes/runtime-errors.d.ts +20 -0
- package/dist/runtimes/runtime-errors.js +95 -0
- package/dist/runtimes/text-cap.d.ts +7 -0
- package/dist/runtimes/text-cap.js +25 -0
- package/dist/transcript.d.ts +13 -0
- package/dist/transcript.js +46 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +17 -0
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +40 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { authFilePath, clearAuth, machineFingerprint, readAuth, writeAuth } from "./auth-store.js";
|
|
7
|
+
import { AgentServiceRunClient } from "./agent-service-client.js";
|
|
8
|
+
import { CourseClient, isAuthFailure, loginCourseDaemon } from "./course-client.js";
|
|
9
|
+
import { runCourseDoctor } from "./doctor.js";
|
|
10
|
+
import { log } from "./log.js";
|
|
11
|
+
import { augmentProcessPath } from "./path-env.js";
|
|
12
|
+
import { redactSecretString } from "./redaction.js";
|
|
13
|
+
import { RunDispatcher } from "./run-dispatcher.js";
|
|
14
|
+
import { RUNTIME_MODULES, detectAvailableRuntimeIds, fakeRuntimeEnabled, } from "./runtimes/index.js";
|
|
15
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
16
|
+
const HELP = `botlearn-course-daemon ${pkg.version} — run BotLearn Course tasks on your own machine (BYOA)
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
botlearn-course-daemon course login --api-url <url> --code <blic_xxx> [--label <name>]
|
|
20
|
+
botlearn-course-daemon course start [--once] [--poll-interval-ms <ms>]
|
|
21
|
+
botlearn-course-daemon course logout
|
|
22
|
+
botlearn-course-daemon course doctor
|
|
23
|
+
botlearn-course-daemon agent-service run
|
|
24
|
+
botlearn-course-daemon --help | --version
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
course login Bind this machine to BotLearn Course using a one-time
|
|
28
|
+
install code. Writes <daemon-home>/auth.json (0600).
|
|
29
|
+
course start Poll Course Service, execute assigned runs with local
|
|
30
|
+
agent runtimes, report run events back.
|
|
31
|
+
course logout Delete the local daemon credential (auth.json).
|
|
32
|
+
course doctor Probe installed runtimes and local auth state.
|
|
33
|
+
|
|
34
|
+
Environment:
|
|
35
|
+
BOTLEARN_COURSE_API_URL default --api-url for course login
|
|
36
|
+
BOTLEARN_DAEMON_HOME state dir (default ~/.botlearn-course/daemon)
|
|
37
|
+
BOTLEARN_DAEMON_DEBUG enable debug logging
|
|
38
|
+
BOTLEARN_<ID>_BIN override a runtime binary path (e.g. BOTLEARN_CODEX_BIN)
|
|
39
|
+
|
|
40
|
+
Agent Service sandbox environment:
|
|
41
|
+
BOTLEARN_COURSE_API_URL
|
|
42
|
+
BOTLEARN_AGENT_SERVICE_RUN_ID
|
|
43
|
+
BOTLEARN_AGENT_SERVICE_RUN_TOKEN
|
|
44
|
+
BOTLEARN_AGENT_SERVICE_WORKER_ID`;
|
|
45
|
+
// ---------------------------------------------------------------
|
|
46
|
+
// flag parser(极简:--k v / --k=v / 布尔开关)
|
|
47
|
+
// ---------------------------------------------------------------
|
|
48
|
+
const BOOLEAN_FLAGS = new Set(["once", "help", "version"]);
|
|
49
|
+
const AGENT_SERVICE_CONTROL_ENV_KEYS = [
|
|
50
|
+
"BOTLEARN_COURSE_API_URL",
|
|
51
|
+
"BOTLEARN_AGENT_SERVICE_RUN_ID",
|
|
52
|
+
"BOTLEARN_AGENT_SERVICE_RUN_TOKEN",
|
|
53
|
+
"BOTLEARN_AGENT_SERVICE_WORKER_ID",
|
|
54
|
+
];
|
|
55
|
+
/** Keep the run-scoped Course credential out of runtime subprocess environments. */
|
|
56
|
+
export function clearAgentServiceControlEnv(env = process.env) {
|
|
57
|
+
for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
|
|
58
|
+
delete env[key];
|
|
59
|
+
}
|
|
60
|
+
export function parseCliArgs(argv) {
|
|
61
|
+
const positional = [];
|
|
62
|
+
const flags = {};
|
|
63
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
64
|
+
const token = argv[i];
|
|
65
|
+
if (token === "-h") {
|
|
66
|
+
flags.help = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token === "-V" || token === "-v") {
|
|
70
|
+
flags.version = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!token.startsWith("--")) {
|
|
74
|
+
positional.push(token);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const body = token.slice(2);
|
|
78
|
+
const eq = body.indexOf("=");
|
|
79
|
+
if (eq >= 0) {
|
|
80
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (BOOLEAN_FLAGS.has(body)) {
|
|
84
|
+
flags[body] = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const next = argv[i + 1];
|
|
88
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
89
|
+
flags[body] = next;
|
|
90
|
+
i += 1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
flags[body] = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { positional, flags };
|
|
97
|
+
}
|
|
98
|
+
function stringFlag(args, name) {
|
|
99
|
+
const value = args.flags[name];
|
|
100
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
function numberFlag(args, name, fallback) {
|
|
103
|
+
const raw = stringFlag(args, name);
|
|
104
|
+
if (raw === undefined)
|
|
105
|
+
return fallback;
|
|
106
|
+
const n = Number(raw);
|
|
107
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
108
|
+
}
|
|
109
|
+
// 默认 sleep 切成小片轮询 isStopping,让 SIGINT 不用等完整个退避窗口。
|
|
110
|
+
async function interruptibleSleep(ms, isStopping) {
|
|
111
|
+
const deadline = Date.now() + ms;
|
|
112
|
+
while (!isStopping()) {
|
|
113
|
+
const remaining = deadline - Date.now();
|
|
114
|
+
if (remaining <= 0)
|
|
115
|
+
return;
|
|
116
|
+
await new Promise((r) => setTimeout(r, Math.min(remaining, 200)));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function runPollLoop(deps) {
|
|
120
|
+
const logger = deps.log ?? log;
|
|
121
|
+
const maxBackoffMs = deps.maxBackoffMs ?? 60_000;
|
|
122
|
+
const sleep = deps.sleep ?? ((ms) => interruptibleSleep(ms, deps.isStopping));
|
|
123
|
+
let failures = 0;
|
|
124
|
+
while (!deps.isStopping()) {
|
|
125
|
+
let payload;
|
|
126
|
+
try {
|
|
127
|
+
payload = await deps.claimNextRun();
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (isAuthFailure(err))
|
|
131
|
+
return "auth_failure";
|
|
132
|
+
const backoffMs = Math.min(deps.pollIntervalMs * 2 ** failures, maxBackoffMs);
|
|
133
|
+
failures += 1;
|
|
134
|
+
logger.warn("course run claim failed; backing off", {
|
|
135
|
+
error: err instanceof Error ? redactSecretString(err.message) : String(err),
|
|
136
|
+
backoffMs,
|
|
137
|
+
});
|
|
138
|
+
await sleep(backoffMs);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
failures = 0;
|
|
142
|
+
if (payload) {
|
|
143
|
+
const runId = payload.agent_run_id;
|
|
144
|
+
const task = deps.dispatch(payload).catch((err) => {
|
|
145
|
+
logger.error("course run dispatch failed", {
|
|
146
|
+
agentRunId: runId,
|
|
147
|
+
error: err instanceof Error ? err.message : String(err),
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
if (deps.once) {
|
|
151
|
+
await task;
|
|
152
|
+
return "once";
|
|
153
|
+
}
|
|
154
|
+
// 有任务:立即继续 claim,run 在后台并发执行。
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (deps.once)
|
|
158
|
+
return "once";
|
|
159
|
+
await sleep(deps.pollIntervalMs);
|
|
160
|
+
}
|
|
161
|
+
return "stopped";
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------
|
|
164
|
+
// 子命令
|
|
165
|
+
// ---------------------------------------------------------------
|
|
166
|
+
function defaultLoginLabel() {
|
|
167
|
+
const name = hostname().replace(/\.local$/, "").trim();
|
|
168
|
+
return name || "daemon";
|
|
169
|
+
}
|
|
170
|
+
async function cmdLogin(args) {
|
|
171
|
+
const courseApiUrl = stringFlag(args, "api-url") ?? process.env.BOTLEARN_COURSE_API_URL;
|
|
172
|
+
const code = stringFlag(args, "code");
|
|
173
|
+
if (!courseApiUrl || !code) {
|
|
174
|
+
console.error("usage: botlearn-course-daemon course login --api-url <url> --code <blic_xxx> [--label <name>]");
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
augmentProcessPath();
|
|
178
|
+
const auth = await loginCourseDaemon({
|
|
179
|
+
courseApiUrl,
|
|
180
|
+
code,
|
|
181
|
+
label: stringFlag(args, "label") ?? defaultLoginLabel(),
|
|
182
|
+
machineFingerprint: machineFingerprint(),
|
|
183
|
+
capabilities: { poll: true, runtimes: await detectAvailableRuntimeIds() },
|
|
184
|
+
});
|
|
185
|
+
writeAuth(auth);
|
|
186
|
+
console.log(`BotLearn Course daemon linked as ${auth.daemonId}`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
async function cmdStart(args) {
|
|
190
|
+
const auth = readAuth();
|
|
191
|
+
if (!auth) {
|
|
192
|
+
console.error("not linked to BotLearn Course");
|
|
193
|
+
console.error("run: botlearn-course-daemon course login --api-url <url> --code <blic_xxx>");
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
augmentProcessPath();
|
|
197
|
+
const pollIntervalMs = numberFlag(args, "poll-interval-ms", 2_000);
|
|
198
|
+
const once = args.flags.once === true;
|
|
199
|
+
const client = CourseClient.fromAuth(auth, { onAuthRefreshed: writeAuth });
|
|
200
|
+
const runtimes = new Map();
|
|
201
|
+
for (const mod of RUNTIME_MODULES) {
|
|
202
|
+
if (mod.hidden && !fakeRuntimeEnabled())
|
|
203
|
+
continue;
|
|
204
|
+
runtimes.set(mod.id, mod.create());
|
|
205
|
+
}
|
|
206
|
+
const dispatcher = new RunDispatcher(client, runtimes);
|
|
207
|
+
let stopping = false;
|
|
208
|
+
let signalCount = 0;
|
|
209
|
+
const onSignal = (sig) => {
|
|
210
|
+
signalCount += 1;
|
|
211
|
+
if (signalCount > 1) {
|
|
212
|
+
// 二次信号:不再等待 drain,取消所有 run 立即退出。
|
|
213
|
+
dispatcher.cancelAll();
|
|
214
|
+
process.exit(130);
|
|
215
|
+
}
|
|
216
|
+
stopping = true;
|
|
217
|
+
log.info("course daemon signal received; draining", { sig });
|
|
218
|
+
};
|
|
219
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
220
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
221
|
+
console.log(`BotLearn Course daemon polling ${auth.courseApiUrl}`);
|
|
222
|
+
const exit = await runPollLoop({
|
|
223
|
+
claimNextRun: () => client.claimNextRun(),
|
|
224
|
+
dispatch: (payload) => dispatcher.dispatch(payload),
|
|
225
|
+
isStopping: () => stopping,
|
|
226
|
+
pollIntervalMs,
|
|
227
|
+
once,
|
|
228
|
+
log,
|
|
229
|
+
});
|
|
230
|
+
if (exit === "auth_failure") {
|
|
231
|
+
clearAuth();
|
|
232
|
+
console.error("daemon credentials expired or revoked — run `botlearn-course-daemon course login` again");
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
// 停止 claim 后先等 inflight 排空;超时强制取消再给 5s 收尾。
|
|
236
|
+
if (!(await dispatcher.drain(10_000))) {
|
|
237
|
+
log.warn("drain timed out; cancelling in-flight runs", { active: dispatcher.activeCount });
|
|
238
|
+
dispatcher.cancelAll();
|
|
239
|
+
await dispatcher.drain(5_000);
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
function cmdLogout() {
|
|
244
|
+
const removed = clearAuth();
|
|
245
|
+
console.log(removed
|
|
246
|
+
? `removed local daemon credentials (${authFilePath()})`
|
|
247
|
+
: "no local daemon credentials found");
|
|
248
|
+
console.log("note: to revoke this daemon server-side, use the daemon list in the frontend");
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
async function cmdDoctor() {
|
|
252
|
+
augmentProcessPath();
|
|
253
|
+
const result = await runCourseDoctor({ includeHidden: fakeRuntimeEnabled() });
|
|
254
|
+
console.log(result.text);
|
|
255
|
+
// doctor 是诊断不是校验:即使 0 个 runtime 可用也 exit 0。
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
async function cmdAgentServiceRun() {
|
|
259
|
+
const courseApiUrl = process.env.BOTLEARN_COURSE_API_URL;
|
|
260
|
+
const agentRunId = process.env.BOTLEARN_AGENT_SERVICE_RUN_ID;
|
|
261
|
+
const runToken = process.env.BOTLEARN_AGENT_SERVICE_RUN_TOKEN;
|
|
262
|
+
const workerId = process.env.BOTLEARN_AGENT_SERVICE_WORKER_ID;
|
|
263
|
+
if (!courseApiUrl || !agentRunId || !runToken || !workerId) {
|
|
264
|
+
console.error("Agent Service sandbox environment is incomplete");
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
augmentProcessPath();
|
|
268
|
+
const client = new AgentServiceRunClient(courseApiUrl, agentRunId, runToken, workerId);
|
|
269
|
+
const payload = await client.getRun();
|
|
270
|
+
if (payload.agent_run_id !== agentRunId) {
|
|
271
|
+
throw new Error("Agent Service run payload does not match requested run");
|
|
272
|
+
}
|
|
273
|
+
clearAgentServiceControlEnv();
|
|
274
|
+
const runtimes = new Map();
|
|
275
|
+
for (const mod of RUNTIME_MODULES) {
|
|
276
|
+
if (mod.hidden && !fakeRuntimeEnabled())
|
|
277
|
+
continue;
|
|
278
|
+
runtimes.set(mod.id, mod.create());
|
|
279
|
+
}
|
|
280
|
+
const dispatcher = new RunDispatcher(client, runtimes);
|
|
281
|
+
const heartbeat = setInterval(() => {
|
|
282
|
+
void client.heartbeat().catch((err) => {
|
|
283
|
+
log.warn("Agent Service heartbeat failed", {
|
|
284
|
+
agentRunId,
|
|
285
|
+
error: err instanceof Error ? redactSecretString(err.message) : String(err),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}, 20_000);
|
|
289
|
+
if (typeof heartbeat.unref === "function")
|
|
290
|
+
heartbeat.unref();
|
|
291
|
+
try {
|
|
292
|
+
await client.heartbeat();
|
|
293
|
+
await dispatcher.dispatch(payload);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
clearInterval(heartbeat);
|
|
297
|
+
}
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------
|
|
301
|
+
// 入口
|
|
302
|
+
// ---------------------------------------------------------------
|
|
303
|
+
export async function runCli(argv) {
|
|
304
|
+
const args = parseCliArgs(argv);
|
|
305
|
+
if (args.flags.version === true) {
|
|
306
|
+
console.log(pkg.version);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
if (args.flags.help === true || args.positional[0] === "help") {
|
|
310
|
+
console.log(HELP);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
const [command, sub] = args.positional;
|
|
314
|
+
if (command === "course") {
|
|
315
|
+
switch (sub) {
|
|
316
|
+
case "login":
|
|
317
|
+
return cmdLogin(args);
|
|
318
|
+
case "start":
|
|
319
|
+
return cmdStart(args);
|
|
320
|
+
case "logout":
|
|
321
|
+
return cmdLogout();
|
|
322
|
+
case "doctor":
|
|
323
|
+
return cmdDoctor();
|
|
324
|
+
default:
|
|
325
|
+
console.error(HELP);
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (command === "agent-service" && sub === "run") {
|
|
330
|
+
return cmdAgentServiceRun();
|
|
331
|
+
}
|
|
332
|
+
console.error(HELP);
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
function isMainModule() {
|
|
336
|
+
const entry = process.argv[1];
|
|
337
|
+
if (!entry)
|
|
338
|
+
return false;
|
|
339
|
+
try {
|
|
340
|
+
return realpathSync(entry) === fileURLToPath(import.meta.url);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (isMainModule()) {
|
|
347
|
+
runCli(process.argv.slice(2))
|
|
348
|
+
.then((code) => process.exit(code))
|
|
349
|
+
.catch((err) => {
|
|
350
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
351
|
+
console.error(redactSecretString(message));
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CourseRuntimeProfile, DaemonAuth, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
2
|
+
export declare class CourseClientError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
constructor(status: number, message: string);
|
|
5
|
+
}
|
|
6
|
+
/** 401:daemon 凭据失效(refresh 也救不回来),调用方应引导重新 login。 */
|
|
7
|
+
export declare function isAuthFailure(err: unknown): boolean;
|
|
8
|
+
/** 409:run 在服务端已进入终态(如用户取消),后续上报应静默停止。 */
|
|
9
|
+
export declare function isRunTerminal(err: unknown): boolean;
|
|
10
|
+
export interface CourseClientOptions {
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
onAuthRefreshed?: (auth: DaemonAuth) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Course Service HTTP 客户端。
|
|
16
|
+
*
|
|
17
|
+
* Course Service 是真相源;client 只用短期 access token 回报已分配给本 daemon 的 run。
|
|
18
|
+
* 第一版用 POST poll/events,不做 WS。401 时自动用 refresh token 换新并单次重试。
|
|
19
|
+
*/
|
|
20
|
+
export declare class CourseClient {
|
|
21
|
+
private readonly baseUrl;
|
|
22
|
+
private accessToken;
|
|
23
|
+
private refreshToken?;
|
|
24
|
+
private readonly onAuthRefreshed?;
|
|
25
|
+
constructor(baseUrl: string, accessToken: string, opts?: CourseClientOptions);
|
|
26
|
+
static fromAuth(auth: DaemonAuth, opts?: Omit<CourseClientOptions, "refreshToken">): CourseClient;
|
|
27
|
+
setAccessToken(token: string): void;
|
|
28
|
+
private url;
|
|
29
|
+
fetchJson<T>(method: string, p: string, body: unknown, accessToken: string | null): Promise<T>;
|
|
30
|
+
private refreshAuth;
|
|
31
|
+
private request;
|
|
32
|
+
/** 领取下一个分配给本 daemon 的 queued run(无则返回 null)。 */
|
|
33
|
+
claimNextRun(): Promise<RunStartPayload | null>;
|
|
34
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<void>;
|
|
35
|
+
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord | void>;
|
|
36
|
+
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
37
|
+
}
|
|
38
|
+
export interface LoginOptions {
|
|
39
|
+
courseApiUrl: string;
|
|
40
|
+
code: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
machineFingerprint?: string;
|
|
43
|
+
capabilities?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export declare function loginCourseDaemon(opts: LoginOptions): Promise<DaemonAuth>;
|
|
46
|
+
export declare function refreshCourseDaemonSession(courseApiUrl: string, refreshToken: string): Promise<DaemonAuth>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { redactSecretString } from "./redaction.js";
|
|
2
|
+
export class CourseClientError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(status, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.name = "CourseClientError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** 401:daemon 凭据失效(refresh 也救不回来),调用方应引导重新 login。 */
|
|
11
|
+
export function isAuthFailure(err) {
|
|
12
|
+
return err instanceof CourseClientError && err.status === 401;
|
|
13
|
+
}
|
|
14
|
+
/** 409:run 在服务端已进入终态(如用户取消),后续上报应静默停止。 */
|
|
15
|
+
export function isRunTerminal(err) {
|
|
16
|
+
return err instanceof CourseClientError && err.status === 409;
|
|
17
|
+
}
|
|
18
|
+
// 错误信息里只保留响应文本前 500 字符,且先脱敏——绝不回显请求体(可能含 code/token)。
|
|
19
|
+
const ERROR_BODY_MAX_CHARS = 500;
|
|
20
|
+
/**
|
|
21
|
+
* Course Service HTTP 客户端。
|
|
22
|
+
*
|
|
23
|
+
* Course Service 是真相源;client 只用短期 access token 回报已分配给本 daemon 的 run。
|
|
24
|
+
* 第一版用 POST poll/events,不做 WS。401 时自动用 refresh token 换新并单次重试。
|
|
25
|
+
*/
|
|
26
|
+
export class CourseClient {
|
|
27
|
+
baseUrl;
|
|
28
|
+
accessToken;
|
|
29
|
+
refreshToken;
|
|
30
|
+
onAuthRefreshed;
|
|
31
|
+
constructor(baseUrl, accessToken, opts = {}) {
|
|
32
|
+
this.baseUrl = baseUrl;
|
|
33
|
+
this.accessToken = accessToken;
|
|
34
|
+
this.refreshToken = opts.refreshToken;
|
|
35
|
+
this.onAuthRefreshed = opts.onAuthRefreshed;
|
|
36
|
+
}
|
|
37
|
+
static fromAuth(auth, opts = {}) {
|
|
38
|
+
return new CourseClient(auth.courseApiUrl, auth.accessToken, {
|
|
39
|
+
...opts,
|
|
40
|
+
refreshToken: auth.refreshToken,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
setAccessToken(token) {
|
|
44
|
+
this.accessToken = token;
|
|
45
|
+
}
|
|
46
|
+
// courseApiUrl 可能是 host 根,也可能已带 /api/v1 —— 去重避免拼出 /api/v1/api/v1。
|
|
47
|
+
url(p) {
|
|
48
|
+
const base = this.baseUrl.replace(/\/+$/, "");
|
|
49
|
+
if (base.endsWith("/api/v1") && p.startsWith("/api/v1/")) {
|
|
50
|
+
return `${base}${p.slice("/api/v1".length)}`;
|
|
51
|
+
}
|
|
52
|
+
return `${base}${p}`;
|
|
53
|
+
}
|
|
54
|
+
async fetchJson(method, p, body, accessToken) {
|
|
55
|
+
const res = await fetch(this.url(p), {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
|
59
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
60
|
+
},
|
|
61
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const text = await res.text().catch(() => "");
|
|
65
|
+
const detail = redactSecretString(text.slice(0, ERROR_BODY_MAX_CHARS));
|
|
66
|
+
throw new CourseClientError(res.status, `${method} ${p} -> ${res.status} ${detail}`);
|
|
67
|
+
}
|
|
68
|
+
const raw = await res.text();
|
|
69
|
+
return (raw ? JSON.parse(raw) : null);
|
|
70
|
+
}
|
|
71
|
+
async refreshAuth() {
|
|
72
|
+
if (!this.refreshToken)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
const auth = await refreshCourseDaemonSession(this.baseUrl, this.refreshToken);
|
|
76
|
+
this.accessToken = auth.accessToken;
|
|
77
|
+
this.refreshToken = auth.refreshToken;
|
|
78
|
+
this.onAuthRefreshed?.(auth);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async request(method, p, body) {
|
|
86
|
+
try {
|
|
87
|
+
return await this.fetchJson(method, p, body, this.accessToken);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (!(err instanceof CourseClientError) || err.status !== 401)
|
|
91
|
+
throw err;
|
|
92
|
+
const refreshed = await this.refreshAuth();
|
|
93
|
+
if (!refreshed)
|
|
94
|
+
throw err;
|
|
95
|
+
return await this.fetchJson(method, p, body, this.accessToken);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** 领取下一个分配给本 daemon 的 queued run(无则返回 null)。 */
|
|
99
|
+
async claimNextRun() {
|
|
100
|
+
return this.request("GET", "/api/v1/daemon/runs/next");
|
|
101
|
+
}
|
|
102
|
+
async postEvent(agentRunId, event) {
|
|
103
|
+
await this.request("POST", `/api/v1/daemon/runs/${agentRunId}/events`, event);
|
|
104
|
+
}
|
|
105
|
+
async postFile(agentRunId, file) {
|
|
106
|
+
return this.request("POST", `/api/v1/daemon/runs/${agentRunId}/files`, file);
|
|
107
|
+
}
|
|
108
|
+
async getRunRuntimeProfile(agentRunId) {
|
|
109
|
+
return this.request("GET", `/api/v1/daemon/runs/${agentRunId}/runtime-profile`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function authFromResponse(baseUrl, raw) {
|
|
113
|
+
const daemonId = raw.daemonId ?? raw.daemon_id;
|
|
114
|
+
const userId = raw.userId ?? raw.user_id;
|
|
115
|
+
const accessToken = raw.accessToken ?? raw.access_token;
|
|
116
|
+
const refreshToken = raw.refreshToken ?? raw.refresh_token;
|
|
117
|
+
if (!daemonId || !userId || !accessToken || !refreshToken) {
|
|
118
|
+
throw new Error("daemon auth response is missing required fields");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
daemonId,
|
|
122
|
+
userId,
|
|
123
|
+
label: raw.label ?? "",
|
|
124
|
+
accessToken,
|
|
125
|
+
refreshToken,
|
|
126
|
+
courseApiUrl: raw.courseApiUrl ?? raw.course_api_url ?? baseUrl,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function loginCourseDaemon(opts) {
|
|
130
|
+
const client = new CourseClient(opts.courseApiUrl, "");
|
|
131
|
+
const raw = await client.fetchJson("POST", "/api/v1/daemon/login", {
|
|
132
|
+
code: opts.code,
|
|
133
|
+
label: opts.label ?? "",
|
|
134
|
+
machine_fingerprint: opts.machineFingerprint,
|
|
135
|
+
capabilities: opts.capabilities ?? {},
|
|
136
|
+
}, null);
|
|
137
|
+
return authFromResponse(opts.courseApiUrl, raw);
|
|
138
|
+
}
|
|
139
|
+
export async function refreshCourseDaemonSession(courseApiUrl, refreshToken) {
|
|
140
|
+
const client = new CourseClient(courseApiUrl, "");
|
|
141
|
+
const raw = await client.fetchJson("POST", "/api/v1/daemon/session", { refresh_token: refreshToken }, null);
|
|
142
|
+
return authFromResponse(courseApiUrl, raw);
|
|
143
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RuntimeProbeEntry } from "./types.js";
|
|
2
|
+
export interface DoctorResult {
|
|
3
|
+
text: string;
|
|
4
|
+
availableCount: number;
|
|
5
|
+
totalCount: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function renderRuntimeTable(entries: RuntimeProbeEntry[]): string[];
|
|
8
|
+
export interface CourseDoctorOptions {
|
|
9
|
+
includeHidden?: boolean;
|
|
10
|
+
/** 探测函数注入点(测试用);缺省走真实 registry probe。 */
|
|
11
|
+
detect?: (opts: {
|
|
12
|
+
includeHidden?: boolean;
|
|
13
|
+
}) => Promise<RuntimeProbeEntry[]>;
|
|
14
|
+
}
|
|
15
|
+
export declare function runCourseDoctor(opts?: CourseDoctorOptions): Promise<DoctorResult>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { authFilePath, readAuth } from "./auth-store.js";
|
|
3
|
+
import { detectRuntimes, getRuntimeModule } from "./runtimes/index.js";
|
|
4
|
+
function pad(s, n) {
|
|
5
|
+
return s + " ".repeat(Math.max(0, n - s.length));
|
|
6
|
+
}
|
|
7
|
+
// token 绝不出现在 doctor 输出:Auth 区只显示非敏感身份字段。
|
|
8
|
+
function renderAuthSection() {
|
|
9
|
+
const lines = ["Auth"];
|
|
10
|
+
const file = authFilePath();
|
|
11
|
+
const auth = readAuth();
|
|
12
|
+
if (!auth) {
|
|
13
|
+
const state = existsSync(file) ? "unreadable" : "missing";
|
|
14
|
+
lines.push(` auth.json: ${state} (${file})`);
|
|
15
|
+
lines.push(" run: botlearn-course-daemon course login --api-url <url> --code <blic_xxx>");
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
lines.push(` auth.json: present (${file})`);
|
|
19
|
+
lines.push(` courseApiUrl: ${auth.courseApiUrl}`);
|
|
20
|
+
lines.push(` daemonId: ${auth.daemonId}`);
|
|
21
|
+
lines.push(` label: ${auth.label || "—"}`);
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
export function renderRuntimeTable(entries) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
const rows = entries.map((e) => ({
|
|
27
|
+
runtime: e.id,
|
|
28
|
+
name: e.displayName,
|
|
29
|
+
status: e.result.available ? "ok" : "missing",
|
|
30
|
+
version: e.result.version ?? "—",
|
|
31
|
+
path: e.result.path ?? "—",
|
|
32
|
+
}));
|
|
33
|
+
const widths = {
|
|
34
|
+
runtime: Math.max(7, ...rows.map((r) => r.runtime.length)),
|
|
35
|
+
name: Math.max(4, ...rows.map((r) => r.name.length)),
|
|
36
|
+
status: Math.max(6, ...rows.map((r) => r.status.length)),
|
|
37
|
+
version: Math.max(7, ...rows.map((r) => r.version.length)),
|
|
38
|
+
};
|
|
39
|
+
lines.push(`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`);
|
|
40
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
41
|
+
const r = rows[i];
|
|
42
|
+
const e = entries[i];
|
|
43
|
+
lines.push(`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`);
|
|
44
|
+
if (!e.result.available && e.installHint) {
|
|
45
|
+
lines.push(` → ${e.installHint}`);
|
|
46
|
+
}
|
|
47
|
+
if (e.result.auth) {
|
|
48
|
+
const auth = e.result.auth;
|
|
49
|
+
const authStatus = auth.checked ? (auth.ok ? "ok" : "failed") : "skipped";
|
|
50
|
+
lines.push(` auth ${authStatus}: ${auth.message ?? ""}`.trimEnd());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
export async function runCourseDoctor(opts = {}) {
|
|
56
|
+
const detect = opts.detect ?? detectRuntimes;
|
|
57
|
+
const entries = await detect({ includeHidden: opts.includeHidden });
|
|
58
|
+
// 登录态探测可能昂贵(真实调用一次 CLI),只在 doctor 里对可用 runtime 补跑;
|
|
59
|
+
// 注入的 entry 已带 auth 时保留,避免测试注入被覆盖。
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (!e.result.available || e.result.auth)
|
|
62
|
+
continue;
|
|
63
|
+
const probeAuth = getRuntimeModule(e.id)?.probeAuth;
|
|
64
|
+
if (!probeAuth)
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
e.result.auth = await probeAuth();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
e.result.auth = {
|
|
71
|
+
checked: true,
|
|
72
|
+
ok: false,
|
|
73
|
+
message: err instanceof Error ? err.message : String(err),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const availableCount = entries.filter((e) => e.result.available).length;
|
|
78
|
+
const lines = [];
|
|
79
|
+
lines.push(...renderAuthSection());
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("Runtimes");
|
|
82
|
+
lines.push(...renderRuntimeTable(entries));
|
|
83
|
+
lines.push(`\n${availableCount}/${entries.length} runtimes available`);
|
|
84
|
+
return { text: lines.join("\n"), availableCount, totalCount: entries.length };
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Logger } from "./log.js";
|
|
2
|
+
import type { RunFileCandidate, RunFileRecord } from "./types.js";
|
|
3
|
+
export interface ScanLimits {
|
|
4
|
+
maxFiles?: number;
|
|
5
|
+
maxFileBytes?: number;
|
|
6
|
+
maxPreviewChars?: number;
|
|
7
|
+
maxDepth?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ScannedFile extends RunFileCandidate {
|
|
10
|
+
absPath: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileReportingClient {
|
|
13
|
+
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord | void>;
|
|
14
|
+
uploadFileContent?(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
|
|
15
|
+
}
|
|
16
|
+
export interface FileReportResult {
|
|
17
|
+
reported: number;
|
|
18
|
+
uploaded: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 递归扫描 workspace 产出文件候选。
|
|
24
|
+
* 跳过:符号链接、隐藏项(`.` 开头)、node_modules、超深、超大、相对路径不安全的项。
|
|
25
|
+
* 达到 maxFiles 上限后停止并标记 truncated。
|
|
26
|
+
*/
|
|
27
|
+
export declare function scanWorkspaceFiles(workspaceDir: string, limits?: ScanLimits): Promise<{
|
|
28
|
+
files: ScannedFile[];
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* 扫描并逐个上报文件候选。单文件上报失败只 warn 不中断;返回成功上报数。
|
|
33
|
+
*/
|
|
34
|
+
export declare function reportFileCandidates(client: FileReportingClient, agentRunId: string, workspaceDir: string, log: Logger, limits?: ScanLimits): Promise<FileReportResult>;
|