@botlearn-course/daemon 0.0.7 → 0.0.8
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/dist/agent-service-client.js +8 -8
- package/dist/agent-service-sandbox.d.ts +108 -0
- package/dist/agent-service-sandbox.js +1400 -0
- package/dist/agent-service-ws-protocol.d.ts +15 -7
- package/dist/agent-service-ws-protocol.js +86 -24
- package/dist/cli.js +26 -53
- package/dist/course-client.js +9 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/run-dispatcher.d.ts +3 -0
- package/dist/run-dispatcher.js +21 -2
- package/dist/runtime-env.d.ts +6 -0
- package/dist/runtime-env.js +35 -2
- package/dist/runtime-profile.js +12 -1
- package/dist/runtimes/deepseek-tui.d.ts +3 -2
- package/dist/runtimes/deepseek-tui.js +33 -7
- package/dist/runtimes/engine.js +4 -1
- package/dist/runtimes/fake.js +102 -6
- package/dist/sandbox-supervisor.d.ts +1 -1
- package/dist/sandbox-supervisor.js +23 -23
- package/dist/workspace.d.ts +27 -4
- package/dist/workspace.js +96 -23
- package/package.json +1 -1
- package/dist/agent-service-session.d.ts +0 -67
- package/dist/agent-service-session.js +0 -799
|
@@ -111,6 +111,7 @@ export class DeepseekTuiAdapter {
|
|
|
111
111
|
let handle;
|
|
112
112
|
let countedInFlight = false;
|
|
113
113
|
let releaseTurn;
|
|
114
|
+
const managedActivationId = this.managedActivationId(opts);
|
|
114
115
|
try {
|
|
115
116
|
// The local server has a process-level kill fallback when turn-scoped interrupt
|
|
116
117
|
// fails. Serialize turns so cancelling one run can never terminate another run.
|
|
@@ -126,7 +127,10 @@ export class DeepseekTuiAdapter {
|
|
|
126
127
|
if (handle.idleTimer)
|
|
127
128
|
clearTimeout(handle.idleTimer);
|
|
128
129
|
const headers = authHeaders(handle.token);
|
|
129
|
-
|
|
130
|
+
// Agent Service model credentials are activation-scoped. The local DeepSeek
|
|
131
|
+
// server reads them only at process startup, so its native thread cache cannot
|
|
132
|
+
// safely cross activations; durable Course context rebuilds the new thread.
|
|
133
|
+
let threadId = managedActivationId ? "" : (opts.sessionId?.trim() || "");
|
|
130
134
|
if (threadId && !isValidThreadId(threadId)) {
|
|
131
135
|
return {
|
|
132
136
|
text: "",
|
|
@@ -152,7 +156,7 @@ export class DeepseekTuiAdapter {
|
|
|
152
156
|
const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
153
157
|
return {
|
|
154
158
|
text,
|
|
155
|
-
newSessionId: threadId,
|
|
159
|
+
newSessionId: managedActivationId ? "" : threadId,
|
|
156
160
|
...(runResult.progressDispositions
|
|
157
161
|
? { progressDispositions: runResult.progressDispositions }
|
|
158
162
|
: {}),
|
|
@@ -166,7 +170,7 @@ export class DeepseekTuiAdapter {
|
|
|
166
170
|
const staleSession = Boolean(opts.sessionId) && isMissingThreadHttpError(err);
|
|
167
171
|
return {
|
|
168
172
|
text: "",
|
|
169
|
-
newSessionId: staleSession ? "" : (opts.sessionId ?? ""),
|
|
173
|
+
newSessionId: managedActivationId || staleSession ? "" : (opts.sessionId ?? ""),
|
|
170
174
|
error: `deepseek-tui: ${message}`,
|
|
171
175
|
};
|
|
172
176
|
}
|
|
@@ -174,8 +178,14 @@ export class DeepseekTuiAdapter {
|
|
|
174
178
|
opts.signal.removeEventListener("abort", onAbort);
|
|
175
179
|
if (handle && countedInFlight) {
|
|
176
180
|
handle.inFlight = Math.max(0, handle.inFlight - 1);
|
|
177
|
-
if (!this.explicitServerUrl)
|
|
181
|
+
if (managedActivationId && !this.explicitServerUrl && handle.inFlight === 0) {
|
|
182
|
+
if (PROCESS_POOL.get(POOL_KEY) === handle)
|
|
183
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
184
|
+
shutdownHandle(handle, "managed-activation-finished");
|
|
185
|
+
}
|
|
186
|
+
else if (!this.explicitServerUrl) {
|
|
178
187
|
resetIdle(handle, POOL_KEY);
|
|
188
|
+
}
|
|
179
189
|
}
|
|
180
190
|
releaseTurn?.();
|
|
181
191
|
}
|
|
@@ -194,14 +204,23 @@ export class DeepseekTuiAdapter {
|
|
|
194
204
|
child: nullChild(),
|
|
195
205
|
baseUrl: trimTrailingSlash(this.explicitServerUrl),
|
|
196
206
|
token: this.explicitAuthToken ?? "",
|
|
207
|
+
managedActivationId: null,
|
|
197
208
|
closed: false,
|
|
198
209
|
inFlight: 0,
|
|
199
210
|
stderrTail: "",
|
|
200
211
|
};
|
|
201
212
|
}
|
|
213
|
+
const managedActivationId = this.managedActivationId(opts);
|
|
202
214
|
const existing = PROCESS_POOL.get(POOL_KEY);
|
|
203
|
-
if (existing
|
|
215
|
+
if (existing
|
|
216
|
+
&& !existing.closed
|
|
217
|
+
&& existing.managedActivationId === managedActivationId) {
|
|
204
218
|
return existing;
|
|
219
|
+
}
|
|
220
|
+
if (existing) {
|
|
221
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
222
|
+
shutdownHandle(existing, "activation-scope-changed");
|
|
223
|
+
}
|
|
205
224
|
const port = await findFreePort();
|
|
206
225
|
if (signal.aborted)
|
|
207
226
|
throw abortReason(signal);
|
|
@@ -243,6 +262,7 @@ export class DeepseekTuiAdapter {
|
|
|
243
262
|
child,
|
|
244
263
|
baseUrl,
|
|
245
264
|
token,
|
|
265
|
+
managedActivationId,
|
|
246
266
|
closed: false,
|
|
247
267
|
inFlight: 0,
|
|
248
268
|
stderrTail: "",
|
|
@@ -278,8 +298,8 @@ export class DeepseekTuiAdapter {
|
|
|
278
298
|
return handle;
|
|
279
299
|
}
|
|
280
300
|
/**
|
|
281
|
-
* 不设置 DEEPSEEK_RUNTIME_DIR:server
|
|
282
|
-
*
|
|
301
|
+
* 不设置 DEEPSEEK_RUNTIME_DIR:BYOA server 可跨 run 池化,直接使用用户本机
|
|
302
|
+
* deepseek 默认状态目录(含已登录凭据);Agent Service server 按 activation 回收。
|
|
283
303
|
*/
|
|
284
304
|
spawnEnv(opts, progressMcpConfigPath) {
|
|
285
305
|
const env = {
|
|
@@ -291,6 +311,12 @@ export class DeepseekTuiAdapter {
|
|
|
291
311
|
env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
|
|
292
312
|
return env;
|
|
293
313
|
}
|
|
314
|
+
managedActivationId(opts) {
|
|
315
|
+
if (this.explicitServerUrl)
|
|
316
|
+
return null;
|
|
317
|
+
const value = opts.env?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim();
|
|
318
|
+
return value || null;
|
|
319
|
+
}
|
|
294
320
|
async createThread(baseUrl, headers, opts, signal) {
|
|
295
321
|
const body = {
|
|
296
322
|
workspace: opts.cwd,
|
package/dist/runtimes/engine.js
CHANGED
|
@@ -90,7 +90,10 @@ export function wrapEngineAdapter(id, engine, opts) {
|
|
|
90
90
|
const payload = run.payload;
|
|
91
91
|
// DeepSeek persists and replays the native thread history. The durable Course Service
|
|
92
92
|
// conversation is recovery/bootstrap data, not a second history to inject on every turn.
|
|
93
|
-
|
|
93
|
+
// Agent Service activations intentionally start a fresh local server because their model
|
|
94
|
+
// credential expires with the turn, so a cached thread id cannot suppress durable context.
|
|
95
|
+
const managedActivation = Boolean(run.runtimeEnv?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim());
|
|
96
|
+
const resumesDeepseekThread = id === "deepseek-tui" && !managedActivation && Boolean(run.nativeSessionId?.trim());
|
|
94
97
|
const text = resumesDeepseekThread
|
|
95
98
|
? (payload.input.text ?? "")
|
|
96
99
|
: renderConversationInput(payload);
|
package/dist/runtimes/fake.js
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { RuntimeExecutionError } from "../types.js";
|
|
3
4
|
/** message 里回显的任务简报截断长度。 */
|
|
4
5
|
const BRIEF_PREVIEW_CHARS = 80;
|
|
6
|
+
const RUNTIME_ERROR_SCENARIO = "[[e2e:runtime-error]]";
|
|
7
|
+
const SLOW_RUNTIME_SCENARIO = "[[e2e:slow]]";
|
|
8
|
+
function configuredStepDelayMs(brief) {
|
|
9
|
+
if (brief.includes(SLOW_RUNTIME_SCENARIO))
|
|
10
|
+
return 1_000;
|
|
11
|
+
const configured = Number(process.env.BOTLEARN_FAKE_RUNTIME_STEP_DELAY_MS ?? 0);
|
|
12
|
+
if (!Number.isFinite(configured))
|
|
13
|
+
return 0;
|
|
14
|
+
return Math.max(0, Math.min(5_000, Math.floor(configured)));
|
|
15
|
+
}
|
|
16
|
+
async function pauseBetweenSteps(signal, runtimeId, brief) {
|
|
17
|
+
const delayMs = configuredStepDelayMs(brief);
|
|
18
|
+
throwIfAborted(signal, runtimeId);
|
|
19
|
+
if (delayMs <= 0)
|
|
20
|
+
return;
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
signal.removeEventListener("abort", onAbort);
|
|
24
|
+
resolve();
|
|
25
|
+
}, delayMs);
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(new Error(`${runtimeId} runtime aborted`));
|
|
29
|
+
};
|
|
30
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
5
33
|
/**
|
|
6
34
|
* 确定性假 runtime:不 spawn 任何进程,直接实现 CourseRuntime。
|
|
7
35
|
* 仅供端到端联调/测试(registry 里 hidden,capabilities 需
|
|
@@ -15,18 +43,86 @@ export class FakeRuntime {
|
|
|
15
43
|
}
|
|
16
44
|
async run(run, sink, signal) {
|
|
17
45
|
const brief = run.payload.input.text ?? "";
|
|
18
|
-
const
|
|
46
|
+
const draftPath = path.join(run.workspaceDir, "draft.md");
|
|
47
|
+
const priorDraft = existsSync(draftPath) ? readFileSync(draftPath, "utf8") : "";
|
|
48
|
+
const isContinuation = priorDraft.length > 0;
|
|
49
|
+
if (isContinuation && run.contextRevision !== undefined && !run.nativeSessionId) {
|
|
50
|
+
throw new RuntimeExecutionError("E2E FakeRuntime lost its native session between turns", "runtime_unavailable");
|
|
51
|
+
}
|
|
52
|
+
if (!isContinuation && run.contextRevision !== undefined) {
|
|
53
|
+
await sink.runtimeSession?.(`fake-thread-${run.payload.course_run_id}`);
|
|
54
|
+
}
|
|
55
|
+
const draft = isContinuation
|
|
56
|
+
? [
|
|
57
|
+
"# E2E 草稿",
|
|
58
|
+
"",
|
|
59
|
+
"第一段:这是由真实 Course Daemon 协议生成的初稿内容。",
|
|
60
|
+
"",
|
|
61
|
+
"第二段:本轮读取并延续了上一轮工作区中的 draft.md。",
|
|
62
|
+
].join("\n")
|
|
63
|
+
: [
|
|
64
|
+
"# E2E 草稿",
|
|
65
|
+
"",
|
|
66
|
+
"第一段:这是由真实 Course Daemon 协议生成的初稿内容。",
|
|
67
|
+
].join("\n");
|
|
68
|
+
const finalText = isContinuation
|
|
69
|
+
? `已读取同一工作区的上一轮 draft.md,并补全为标题和两段正文。\n\n${draft}`
|
|
70
|
+
: `已生成包含标题和第一段正文的初稿,请检查后继续完善。\n\n${draft}\n\n任务:${brief.slice(0, BRIEF_PREVIEW_CHARS)}`;
|
|
19
71
|
const blocks = [
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
72
|
+
{
|
|
73
|
+
kind: "status",
|
|
74
|
+
text: "deterministic runtime started",
|
|
75
|
+
raw: { event: "started" },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
kind: "thinking",
|
|
79
|
+
phase: "in_progress",
|
|
80
|
+
text: "正在检查任务和工作区。",
|
|
81
|
+
raw: { event: "reasoning" },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
kind: "tool_call",
|
|
85
|
+
name: "write_file",
|
|
86
|
+
text: "draft.md",
|
|
87
|
+
raw: { event: "tool_call" },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: "progress",
|
|
91
|
+
runtime: this.id,
|
|
92
|
+
summary: isContinuation ? "正在补全草稿" : "正在生成初稿",
|
|
93
|
+
status: "in_progress",
|
|
94
|
+
},
|
|
23
95
|
];
|
|
24
96
|
for (const block of blocks) {
|
|
25
97
|
throwIfAborted(signal, this.id);
|
|
26
98
|
await sink.block(block);
|
|
99
|
+
await pauseBetweenSteps(signal, this.id, brief);
|
|
100
|
+
}
|
|
101
|
+
if (brief.includes(RUNTIME_ERROR_SCENARIO)) {
|
|
102
|
+
throw new RuntimeExecutionError("E2E deterministic runtime failure");
|
|
27
103
|
}
|
|
28
104
|
throwIfAborted(signal, this.id);
|
|
29
|
-
writeFileSync(
|
|
105
|
+
writeFileSync(draftPath, draft, "utf8");
|
|
106
|
+
await sink.block({
|
|
107
|
+
kind: "tool_result",
|
|
108
|
+
name: "write_file",
|
|
109
|
+
status: "completed",
|
|
110
|
+
text: "draft.md",
|
|
111
|
+
raw: { event: "tool_result" },
|
|
112
|
+
});
|
|
113
|
+
await pauseBetweenSteps(signal, this.id, brief);
|
|
114
|
+
await sink.block({
|
|
115
|
+
kind: "thinking",
|
|
116
|
+
phase: "completed",
|
|
117
|
+
raw: { event: "reasoning" },
|
|
118
|
+
});
|
|
119
|
+
await sink.block({
|
|
120
|
+
kind: "progress",
|
|
121
|
+
runtime: this.id,
|
|
122
|
+
summary: isContinuation ? "草稿已补全" : "初稿已生成",
|
|
123
|
+
status: "completed",
|
|
124
|
+
});
|
|
125
|
+
await sink.block({ kind: "text_delta", text: finalText, raw: { event: "text" } });
|
|
30
126
|
await sink.message(finalText);
|
|
31
127
|
}
|
|
32
128
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function acquireSandboxSupervisorLock(sandboxId: string, lockRoot?: string): (() => void) | null;
|
|
3
3
|
export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
|
|
4
4
|
export declare function isMainModule(entry?: string): boolean;
|
|
@@ -49,8 +49,8 @@ async function readOneShotBootstrap() {
|
|
|
49
49
|
throw new Error("sandbox supervisor bootstrap is empty");
|
|
50
50
|
return Buffer.concat(chunks);
|
|
51
51
|
}
|
|
52
|
-
export function
|
|
53
|
-
const lockDir = path.join(lockRoot,
|
|
52
|
+
export function acquireSandboxSupervisorLock(sandboxId, lockRoot = SUPERVISOR_LOCK_ROOT) {
|
|
53
|
+
const lockDir = path.join(lockRoot, sandboxId);
|
|
54
54
|
const pidFile = path.join(lockDir, "pid");
|
|
55
55
|
mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
|
|
56
56
|
try {
|
|
@@ -84,23 +84,23 @@ export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERV
|
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
function prepareDirectories(controlUid, controlGid
|
|
87
|
+
function prepareDirectories(controlUid, controlGid) {
|
|
88
88
|
mkdirSync(CONTROL_HOME, { recursive: true, mode: 0o700 });
|
|
89
89
|
chownSync("/home/botlearn-control/.botlearn-course", controlUid, controlGid);
|
|
90
90
|
chownSync(CONTROL_HOME, controlUid, controlGid);
|
|
91
91
|
chmodSync(CONTROL_HOME, 0o700);
|
|
92
|
-
mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode:
|
|
92
|
+
mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o710 });
|
|
93
93
|
chownSync(RUNTIME_PROFILE_ROOT, controlUid, controlGid);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
chmodSync(
|
|
94
|
+
// Runtime can traverse to the active run profile path named in its instructions,
|
|
95
|
+
// but cannot enumerate profiles belonging to other activations.
|
|
96
|
+
chmodSync(RUNTIME_PROFILE_ROOT, 0o710);
|
|
97
|
+
// /workspace 只准备根目录;per-session 子目录
|
|
98
|
+
// `<root>/<runtime_session_id>/generation-<sandbox_generation>` 由 daemon
|
|
99
|
+
// (botlearn-control)在 session.open 时创建。根目录只允许 runtime 组穿过,
|
|
100
|
+
// 不允许列举 session id;daemon 仅在 activation 期间开放目标子目录。
|
|
101
|
+
mkdirSync(WORKSPACE, { recursive: true, mode: 0o710 });
|
|
102
|
+
chownSync(WORKSPACE, controlUid, controlGid);
|
|
103
|
+
chmodSync(WORKSPACE, 0o710);
|
|
104
104
|
}
|
|
105
105
|
export async function runSandboxSupervisor(argv) {
|
|
106
106
|
if (argv.length !== 2 || argv[0] !== "agent-service" || argv[1] !== "session") {
|
|
@@ -117,18 +117,18 @@ export async function runSandboxSupervisor(argv) {
|
|
|
117
117
|
let releaseLock = null;
|
|
118
118
|
try {
|
|
119
119
|
const decoded = JSON.parse(bootstrap.toString("utf8"));
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
if (typeof
|
|
123
|
-
!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(
|
|
124
|
-
!Number.isInteger(
|
|
125
|
-
|
|
126
|
-
throw new Error("sandbox supervisor bootstrap
|
|
120
|
+
const sandboxId = decoded.sandboxId;
|
|
121
|
+
const sandboxGeneration = Number(decoded.sandboxGeneration ?? 0);
|
|
122
|
+
if (typeof sandboxId !== "string" ||
|
|
123
|
+
!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sandboxId) ||
|
|
124
|
+
!Number.isInteger(sandboxGeneration) ||
|
|
125
|
+
sandboxGeneration < 1) {
|
|
126
|
+
throw new Error("sandbox supervisor bootstrap sandbox scope is invalid");
|
|
127
127
|
}
|
|
128
|
-
releaseLock =
|
|
128
|
+
releaseLock = acquireSandboxSupervisorLock(sandboxId);
|
|
129
129
|
if (releaseLock === null)
|
|
130
130
|
return 0;
|
|
131
|
-
prepareDirectories(controlUid, controlGid
|
|
131
|
+
prepareDirectories(controlUid, controlGid);
|
|
132
132
|
child = spawn(NODE_BINARY, [
|
|
133
133
|
DAEMON_ENTRY,
|
|
134
134
|
"agent-service",
|
package/dist/workspace.d.ts
CHANGED
|
@@ -18,14 +18,37 @@ export declare function runWorkspaceDir(agentRunId: string): string;
|
|
|
18
18
|
export declare function transcriptPath(agentRunId: string): string;
|
|
19
19
|
export declare function runtimeProfileDir(agentRunId: string): string;
|
|
20
20
|
export declare function runtimeProfileRunRootDir(agentRunId: string): string;
|
|
21
|
-
export declare function runtimeSessionRootDir(runtimeSessionId: string,
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
|
|
22
|
+
/**
|
|
23
|
+
* per-session workspace(ADR-015 §7):managed sandbox 下为
|
|
24
|
+
* `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
|
|
25
|
+
* 由 daemon 在 session.open 时创建。
|
|
26
|
+
*/
|
|
27
|
+
export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
|
|
28
|
+
export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
|
|
24
29
|
export declare function ensureRunWorkspace(agentRunId: string): {
|
|
25
30
|
rootDir: string;
|
|
26
31
|
workspaceDir: string;
|
|
27
32
|
};
|
|
28
|
-
|
|
33
|
+
/**
|
|
34
|
+
* session.open 时创建 per-session 目录。managed workspace 默认保持 0700,只有当前
|
|
35
|
+
* activation 会通过 exposeRuntimeSessionWorkspace() 临时开放给 runtime 组。
|
|
36
|
+
*/
|
|
37
|
+
export declare function ensureRuntimeSessionDirectories(runtimeSessionId: string, sandboxGeneration: number): {
|
|
38
|
+
rootDir: string;
|
|
39
|
+
workspaceDir: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* 暴露当前 activation 的 managed workspace。sandbox 根目录由 supervisor 设为
|
|
43
|
+
* 0710,因此 runtime 只能穿过根目录,不能列举其他 session id;未激活 session
|
|
44
|
+
* 的父目录与 workspace 始终为 0700。
|
|
45
|
+
*/
|
|
46
|
+
export declare function exposeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
|
|
47
|
+
/** Revoke a managed workspace without deleting the session's durable local materialization. */
|
|
48
|
+
export declare function revokeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
|
|
49
|
+
/** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
|
|
50
|
+
export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
|
|
51
|
+
export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): {
|
|
29
52
|
rootDir: string;
|
|
30
53
|
workspaceDir: string;
|
|
31
54
|
transcriptFile: string;
|
package/dist/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync,
|
|
1
|
+
import { chmodSync, mkdirSync, rmSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { daemonHome } from "./auth-store.js";
|
|
4
4
|
/**
|
|
@@ -42,34 +42,51 @@ export function runtimeProfileDir(agentRunId) {
|
|
|
42
42
|
export function runtimeProfileRunRootDir(agentRunId) {
|
|
43
43
|
return path.dirname(runtimeProfileDir(agentRunId));
|
|
44
44
|
}
|
|
45
|
-
export function runtimeSessionRootDir(runtimeSessionId,
|
|
45
|
+
export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
|
|
46
46
|
assertSafeId(runtimeSessionId, "runtime_session_id");
|
|
47
|
-
if (!Number.isInteger(
|
|
48
|
-
throw new Error("unsafe
|
|
47
|
+
if (!Number.isInteger(sandboxGeneration) || sandboxGeneration < 1) {
|
|
48
|
+
throw new Error("unsafe sandbox_generation: expected a positive integer");
|
|
49
49
|
}
|
|
50
|
-
return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${
|
|
50
|
+
return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* per-session workspace(ADR-015 §7):managed sandbox 下为
|
|
54
|
+
* `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
|
|
55
|
+
* 由 daemon 在 session.open 时创建。
|
|
56
|
+
*/
|
|
57
|
+
export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
|
|
58
|
+
assertSafeId(runtimeSessionId, "runtime_session_id");
|
|
59
|
+
if (!Number.isInteger(sandboxGeneration) || sandboxGeneration < 1) {
|
|
60
|
+
throw new Error("unsafe sandbox_generation: expected a positive integer");
|
|
61
|
+
}
|
|
53
62
|
const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
|
|
54
63
|
if (managedRoot) {
|
|
55
|
-
return path.join(managedRoot, runtimeSessionId, `generation-${
|
|
64
|
+
return path.join(managedRoot, runtimeSessionId, `generation-${sandboxGeneration}`);
|
|
56
65
|
}
|
|
57
|
-
return path.join(runtimeSessionRootDir(runtimeSessionId,
|
|
66
|
+
return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
|
|
67
|
+
}
|
|
68
|
+
function managedRuntimeSessionParentDir(runtimeSessionId) {
|
|
69
|
+
assertSafeId(runtimeSessionId, "runtime_session_id");
|
|
70
|
+
const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
|
|
71
|
+
return managedRoot ? path.join(managedRoot, runtimeSessionId) : null;
|
|
58
72
|
}
|
|
59
|
-
export function runtimeSessionTranscriptPath(runtimeSessionId,
|
|
73
|
+
export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
|
|
60
74
|
assertSafeId(agentRunId, "agent_run_id");
|
|
61
|
-
return path.join(runtimeSessionRootDir(runtimeSessionId,
|
|
75
|
+
return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "transcripts", `${agentRunId}.jsonl`);
|
|
62
76
|
}
|
|
63
77
|
// recursive mkdir 只对新建目录生效 mode,已存在目录需 best-effort 收紧。
|
|
64
|
-
function mkdirTolerant(dir) {
|
|
65
|
-
mkdirSync(dir, { recursive: true, mode
|
|
78
|
+
function mkdirTolerant(dir, mode = 0o700) {
|
|
79
|
+
mkdirSync(dir, { recursive: true, mode });
|
|
66
80
|
try {
|
|
67
|
-
chmodSync(dir,
|
|
81
|
+
chmodSync(dir, mode);
|
|
68
82
|
}
|
|
69
83
|
catch {
|
|
70
84
|
// Windows 等不支持 chmod 时忽略。
|
|
71
85
|
}
|
|
72
86
|
}
|
|
87
|
+
function isMissingPathError(error) {
|
|
88
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
89
|
+
}
|
|
73
90
|
export function ensureRunWorkspace(agentRunId) {
|
|
74
91
|
const rootDir = runRootDir(agentRunId);
|
|
75
92
|
const workspaceDir = runWorkspaceDir(agentRunId);
|
|
@@ -77,19 +94,75 @@ export function ensureRunWorkspace(agentRunId) {
|
|
|
77
94
|
mkdirTolerant(workspaceDir);
|
|
78
95
|
return { rootDir, workspaceDir };
|
|
79
96
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
/**
|
|
98
|
+
* session.open 时创建 per-session 目录。managed workspace 默认保持 0700,只有当前
|
|
99
|
+
* activation 会通过 exposeRuntimeSessionWorkspace() 临时开放给 runtime 组。
|
|
100
|
+
*/
|
|
101
|
+
export function ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration) {
|
|
102
|
+
const rootDir = runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
|
|
103
|
+
const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
|
|
84
104
|
mkdirTolerant(rootDir);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
|
|
106
|
+
if (managedParent)
|
|
107
|
+
mkdirTolerant(managedParent, 0o700);
|
|
108
|
+
mkdirTolerant(workspaceDir, 0o700);
|
|
109
|
+
return { rootDir, workspaceDir };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 暴露当前 activation 的 managed workspace。sandbox 根目录由 supervisor 设为
|
|
113
|
+
* 0710,因此 runtime 只能穿过根目录,不能列举其他 session id;未激活 session
|
|
114
|
+
* 的父目录与 workspace 始终为 0700。
|
|
115
|
+
*/
|
|
116
|
+
export function exposeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
|
|
117
|
+
const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
|
|
118
|
+
if (!managedParent)
|
|
119
|
+
return;
|
|
120
|
+
const { workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
|
|
121
|
+
// Parent is traverse-only for the runtime group: allowing group write here would let
|
|
122
|
+
// the runtime replace the generation directory with a symlink before revoke/cleanup.
|
|
123
|
+
chmodSync(managedParent, 0o710);
|
|
124
|
+
chmodSync(workspaceDir, 0o770);
|
|
125
|
+
}
|
|
126
|
+
/** Revoke a managed workspace without deleting the session's durable local materialization. */
|
|
127
|
+
export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
|
|
128
|
+
const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
|
|
129
|
+
if (!managedParent)
|
|
130
|
+
return;
|
|
131
|
+
const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
|
|
132
|
+
// Revoke the leaf before the parent so a runtime loses access as early as possible.
|
|
133
|
+
try {
|
|
134
|
+
chmodSync(workspaceDir, 0o700);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// session.close / generation cleanup may already have removed it. Permission and I/O
|
|
138
|
+
// failures must remain visible: proceeding would leave an inactive workspace exposed.
|
|
139
|
+
if (!isMissingPathError(error))
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
chmodSync(managedParent, 0o700);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (!isMissingPathError(error))
|
|
147
|
+
throw error;
|
|
89
148
|
}
|
|
90
|
-
|
|
91
|
-
|
|
149
|
+
}
|
|
150
|
+
/** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
|
|
151
|
+
export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
|
|
152
|
+
// 校验路径段安全后按 session 整体删除(含全部 generation 与 transcripts)。
|
|
153
|
+
runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
|
|
154
|
+
rmSync(path.join(daemonHome(), "agent-service-sessions", runtimeSessionId), {
|
|
155
|
+
recursive: true,
|
|
156
|
+
force: true,
|
|
157
|
+
});
|
|
158
|
+
const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
|
|
159
|
+
if (managedRoot) {
|
|
160
|
+
rmSync(path.join(managedRoot, runtimeSessionId), { recursive: true, force: true });
|
|
92
161
|
}
|
|
162
|
+
}
|
|
163
|
+
export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId) {
|
|
164
|
+
const { rootDir, workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
|
|
165
|
+
const transcriptFile = runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId);
|
|
93
166
|
mkdirTolerant(path.dirname(transcriptFile));
|
|
94
167
|
return { rootDir, workspaceDir, transcriptFile };
|
|
95
168
|
}
|
package/package.json
CHANGED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { type Logger } from "./log.js";
|
|
2
|
-
import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
|
|
3
|
-
import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
4
|
-
export interface AgentServiceSessionOptions {
|
|
5
|
-
wsUrl: string;
|
|
6
|
-
sessionId: string;
|
|
7
|
-
sessionToken: string;
|
|
8
|
-
runtimes: Map<string, CourseRuntime>;
|
|
9
|
-
daemonVersion: string;
|
|
10
|
-
runtimeEnv?: Record<string, string>;
|
|
11
|
-
log?: Logger;
|
|
12
|
-
random?: () => number;
|
|
13
|
-
sleep?: (ms: number) => Promise<void>;
|
|
14
|
-
}
|
|
15
|
-
/** Long-running daemon client for one persistent managed sandbox session. */
|
|
16
|
-
export declare class AgentServiceSessionClient implements RunReportingClient, PersistentSessionExecution {
|
|
17
|
-
private readonly options;
|
|
18
|
-
private readonly log;
|
|
19
|
-
private readonly random;
|
|
20
|
-
private readonly sleep;
|
|
21
|
-
private readonly state;
|
|
22
|
-
private readonly dispatcher;
|
|
23
|
-
private readonly attempts;
|
|
24
|
-
private readonly runtimeProfiles;
|
|
25
|
-
private readonly pendingAcks;
|
|
26
|
-
private readonly pendingFilePrepares;
|
|
27
|
-
private readonly pendingFileCommits;
|
|
28
|
-
private readonly fileGrants;
|
|
29
|
-
private readonly inflightCommands;
|
|
30
|
-
private socket;
|
|
31
|
-
private sessionGeneration;
|
|
32
|
-
private connectionEpoch;
|
|
33
|
-
private inboundSeq;
|
|
34
|
-
private heartbeatMs;
|
|
35
|
-
private staleMs;
|
|
36
|
-
private stopped;
|
|
37
|
-
private permanentFailure;
|
|
38
|
-
constructor(options: AgentServiceSessionOptions);
|
|
39
|
-
prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
|
|
40
|
-
persistNativeSession(sessionId: string): void;
|
|
41
|
-
run(): Promise<void>;
|
|
42
|
-
stop(): void;
|
|
43
|
-
postEvent(agentRunId: string, event: RunEvent): Promise<void>;
|
|
44
|
-
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
|
|
45
|
-
uploadFileContent(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
|
|
46
|
-
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
47
|
-
private connectOnce;
|
|
48
|
-
private handleHello;
|
|
49
|
-
private assertServerFrame;
|
|
50
|
-
private handleServerFrame;
|
|
51
|
-
private applyDesiredState;
|
|
52
|
-
private reconcileInterruptedCommand;
|
|
53
|
-
private runtimeActivation;
|
|
54
|
-
private ackEventOrFile;
|
|
55
|
-
private acceptFileGrant;
|
|
56
|
-
private replaySpool;
|
|
57
|
-
private sendHeartbeat;
|
|
58
|
-
private sendCommandAck;
|
|
59
|
-
private sendControlFrame;
|
|
60
|
-
private sendFrame;
|
|
61
|
-
private nextOutboundSeq;
|
|
62
|
-
private spoolBytes;
|
|
63
|
-
private enforceSpoolLimit;
|
|
64
|
-
private rejectPending;
|
|
65
|
-
private rejectPendingFiles;
|
|
66
|
-
private persist;
|
|
67
|
-
}
|