@agentstep/agent-sdk 0.1.0
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/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped sprite lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* - Sprites are reserved **lazily** on the first user.message, not at
|
|
5
|
+
* `POST /v1/sessions` time. This keeps session creation fast and decoupled
|
|
6
|
+
* from sprite provisioning latency.
|
|
7
|
+
* - Each session is pinned 1:1 to a sprite for its lifetime. No rebalancing.
|
|
8
|
+
* - Park/restore as originally planned is infeasible: spike S2 proved
|
|
9
|
+
* sprites.dev checkpoints are per-sprite only (no cross-sprite restore)
|
|
10
|
+
* and there is no stopSprite/suspendSprite API. The correct M5 model is
|
|
11
|
+
* idle eviction — see `lib/sessions/sweeper.ts`.
|
|
12
|
+
* - Orphan reconciliation runs on startup and periodically from the sweeper.
|
|
13
|
+
*/
|
|
14
|
+
import { createSprite, deleteSprite, listSprites } from "./client";
|
|
15
|
+
import * as pool from "./pool";
|
|
16
|
+
import { installClaudeWrapper } from "../backends/claude/wrapper-script";
|
|
17
|
+
import { resolveBackend } from "../backends/registry";
|
|
18
|
+
import { getAgent } from "../db/agents";
|
|
19
|
+
import { getEnvironment, getEnvironmentRow } from "../db/environments";
|
|
20
|
+
import {
|
|
21
|
+
getSession,
|
|
22
|
+
getSessionRow,
|
|
23
|
+
setSessionSprite,
|
|
24
|
+
} from "../db/sessions";
|
|
25
|
+
import { appendEvent } from "../sessions/bus";
|
|
26
|
+
import { getConfig } from "../config";
|
|
27
|
+
import { ApiError } from "../errors";
|
|
28
|
+
import { nowMs } from "../util/clock";
|
|
29
|
+
import { resolveContainerProvider } from "../providers/registry";
|
|
30
|
+
import { dockerProvider } from "../providers/docker";
|
|
31
|
+
import { getVault, listEntries } from "../db/vaults";
|
|
32
|
+
import type { SessionResource } from "../types";
|
|
33
|
+
|
|
34
|
+
const SPRITE_NAME_PREFIX = "ca-sess-";
|
|
35
|
+
|
|
36
|
+
function deriveSpriteName(sessionId: string): string {
|
|
37
|
+
// Stable prefix + ULID tail, lowercased (sprites.dev requires lowercase names).
|
|
38
|
+
return `${SPRITE_NAME_PREFIX}${sessionId.replace(/^sess_/, "").toLowerCase()}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acquire a sprite for the session if one is not already bound. Called
|
|
43
|
+
* from the driver on the first turn (not from the session create route).
|
|
44
|
+
*
|
|
45
|
+
* Runs the agent's backend-specific `prepareOnSprite` hook after the sprite
|
|
46
|
+
* is created. For claude this is a sub-second wrapper install; for opencode
|
|
47
|
+
* it's a ~10-second npm install + symlink.
|
|
48
|
+
*
|
|
49
|
+
* For backends with non-trivial prep time, emits
|
|
50
|
+
* `span.environment_setup_{start,end}` events so streaming clients can see
|
|
51
|
+
* the delay isn't a hang. Claude's prep is fast enough that we skip the
|
|
52
|
+
* spans to avoid noise.
|
|
53
|
+
*/
|
|
54
|
+
export async function acquireForFirstTurn(sessionId: string): Promise<string> {
|
|
55
|
+
const row = getSessionRow(sessionId);
|
|
56
|
+
if (!row) throw new ApiError(404, "not_found_error", `session not found: ${sessionId}`);
|
|
57
|
+
if (row.sprite_name) return row.sprite_name;
|
|
58
|
+
|
|
59
|
+
const env = getEnvironmentRow(row.environment_id);
|
|
60
|
+
if (!env) throw new ApiError(404, "not_found_error", "environment not found");
|
|
61
|
+
if (env.state !== "ready") {
|
|
62
|
+
throw new ApiError(
|
|
63
|
+
400,
|
|
64
|
+
"invalid_request_error",
|
|
65
|
+
`environment is not ready (state=${env.state})`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pool.countInEnv(env.id) >= getConfig().maxSpritesPerEnv) {
|
|
70
|
+
throw new ApiError(503, "server_busy", "env sprite pool exhausted");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const agent = getAgent(row.agent_id, row.agent_version);
|
|
74
|
+
if (!agent) {
|
|
75
|
+
throw new ApiError(404, "not_found_error", "agent not found for session");
|
|
76
|
+
}
|
|
77
|
+
const backend = resolveBackend(agent.backend);
|
|
78
|
+
|
|
79
|
+
// Resolve the container provider from the environment config.
|
|
80
|
+
// Defaults to "sprites" for backward compatibility.
|
|
81
|
+
const envObj = getEnvironment(row.environment_id);
|
|
82
|
+
const provider = await resolveContainerProvider(envObj?.config?.provider);
|
|
83
|
+
|
|
84
|
+
// Pre-flight: re-check provider availability (may have changed since env creation)
|
|
85
|
+
if (provider.checkAvailability) {
|
|
86
|
+
const result = await provider.checkAvailability();
|
|
87
|
+
if (!result.available) {
|
|
88
|
+
throw new Error(`provider ${provider.name} is not available: ${result.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const name = deriveSpriteName(sessionId);
|
|
93
|
+
await provider.create({ name });
|
|
94
|
+
|
|
95
|
+
// Backends with slow prep (e.g. opencode's npm install) bracket the work
|
|
96
|
+
// with span events so the client sees something on the event stream
|
|
97
|
+
// rather than a minute of silence. Non-spec event type — clients that
|
|
98
|
+
// don't recognize it should drop it silently.
|
|
99
|
+
const needsSlowPrep = backend.name !== "claude";
|
|
100
|
+
if (needsSlowPrep) {
|
|
101
|
+
appendEvent(sessionId, {
|
|
102
|
+
type: "span.environment_setup_start",
|
|
103
|
+
payload: { backend: backend.name },
|
|
104
|
+
origin: "server",
|
|
105
|
+
processedAt: nowMs(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await backend.prepareOnSprite(name, provider);
|
|
111
|
+
|
|
112
|
+
// Install custom tool bridge if the agent has custom tools or threads_enabled (claude backend only)
|
|
113
|
+
if (agent.backend === "claude") {
|
|
114
|
+
const customTools = agent.tools.filter(
|
|
115
|
+
(t): t is import("../types").CustomTool => t.type === "custom",
|
|
116
|
+
);
|
|
117
|
+
// If threads are enabled, add spawn_agent as a synthetic custom tool
|
|
118
|
+
const allBridgeTools = [...customTools];
|
|
119
|
+
if (agent.threads_enabled) {
|
|
120
|
+
allBridgeTools.push({
|
|
121
|
+
type: "custom",
|
|
122
|
+
name: "spawn_agent",
|
|
123
|
+
description: "Spawn a sub-agent to handle a task. Returns the sub-agent's response.",
|
|
124
|
+
input_schema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
agent_id: { type: "string", description: "ID of the agent to spawn" },
|
|
128
|
+
prompt: { type: "string", description: "Task for the sub-agent" },
|
|
129
|
+
},
|
|
130
|
+
required: ["agent_id", "prompt"],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (allBridgeTools.length > 0) {
|
|
135
|
+
const { installToolBridge } = await import("../backends/claude/index");
|
|
136
|
+
await installToolBridge(name, allBridgeTools, provider);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Install permission hook if confirmation_mode is enabled
|
|
140
|
+
if (agent.confirmation_mode) {
|
|
141
|
+
const { installPermissionHook } = await import("../backends/claude/index");
|
|
142
|
+
await installPermissionHook(name, provider);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
await provider.delete(name).catch(() => {});
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (needsSlowPrep) {
|
|
151
|
+
appendEvent(sessionId, {
|
|
152
|
+
type: "span.environment_setup_end",
|
|
153
|
+
payload: { backend: backend.name },
|
|
154
|
+
origin: "server",
|
|
155
|
+
processedAt: nowMs(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Provision resources into the container if the session has any
|
|
160
|
+
const session = getSession(sessionId);
|
|
161
|
+
if (session?.resources && session.resources.length > 0) {
|
|
162
|
+
await provisionResources(name, session.resources, provider);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Provision vault data into the container if the session has vault_ids
|
|
166
|
+
if (session?.vault_ids && session.vault_ids.length > 0) {
|
|
167
|
+
await provisionVaults(name, session.vault_ids, provider);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
pool.register({
|
|
171
|
+
spriteName: name,
|
|
172
|
+
envId: env.id,
|
|
173
|
+
sessionId,
|
|
174
|
+
createdAt: nowMs(),
|
|
175
|
+
});
|
|
176
|
+
setSessionSprite(sessionId, name);
|
|
177
|
+
return name;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Download/write resources into /tmp/resources/ in the container.
|
|
182
|
+
* URIs are fetched via global fetch; text resources are written directly.
|
|
183
|
+
*/
|
|
184
|
+
async function provisionResources(
|
|
185
|
+
spriteName: string,
|
|
186
|
+
resources: SessionResource[],
|
|
187
|
+
provider: import("../providers/types").ContainerProvider,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
await provider.exec(spriteName, ["mkdir", "-p", "/tmp/resources"]);
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < resources.length; i++) {
|
|
192
|
+
const r = resources[i];
|
|
193
|
+
const filename = `/tmp/resources/resource_${i}`;
|
|
194
|
+
|
|
195
|
+
if (r.type === "uri" && r.uri) {
|
|
196
|
+
const MAX_RESOURCE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
197
|
+
try {
|
|
198
|
+
const resp = await fetch(r.uri, { signal: AbortSignal.timeout(30000) });
|
|
199
|
+
if (!resp.ok) {
|
|
200
|
+
console.warn(`[lifecycle] failed to fetch resource ${r.uri}: ${resp.status}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const contentLength = resp.headers.get("Content-Length");
|
|
204
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESOURCE_BYTES) {
|
|
205
|
+
console.warn(`[lifecycle] skipping resource ${r.uri}: Content-Length ${contentLength} exceeds 50 MB limit`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const content = await resp.text();
|
|
209
|
+
if (Buffer.byteLength(content, "utf8") > MAX_RESOURCE_BYTES) {
|
|
210
|
+
console.warn(`[lifecycle] skipping resource ${r.uri}: body size exceeds 50 MB limit`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
await provider.exec(spriteName, ["bash", "-c", `cat > ${filename}`], { stdin: content });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.warn(`[lifecycle] failed to provision URI resource ${r.uri}:`, err);
|
|
216
|
+
}
|
|
217
|
+
} else if (r.type === "text" && r.content) {
|
|
218
|
+
try {
|
|
219
|
+
await provider.exec(spriteName, ["bash", "-c", `cat > ${filename}`], { stdin: r.content });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.warn(`[lifecycle] failed to provision text resource:`, err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Export vault entries as JSON files into /tmp/vaults/{vault_id}.json in the
|
|
229
|
+
* container. Each file contains `{entries: [{key, value}, ...]}`.
|
|
230
|
+
*/
|
|
231
|
+
async function provisionVaults(
|
|
232
|
+
spriteName: string,
|
|
233
|
+
vaultIds: string[],
|
|
234
|
+
provider: import("../providers/types").ContainerProvider,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
await provider.exec(spriteName, ["mkdir", "-p", "/tmp/vaults"]);
|
|
237
|
+
|
|
238
|
+
for (const vaultId of vaultIds) {
|
|
239
|
+
const vault = getVault(vaultId);
|
|
240
|
+
if (!vault) {
|
|
241
|
+
console.warn(`[lifecycle] vault not found: ${vaultId}, skipping`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const entries = listEntries(vaultId);
|
|
245
|
+
const json = JSON.stringify({ vault_id: vaultId, name: vault.name, entries });
|
|
246
|
+
try {
|
|
247
|
+
await provider.exec(
|
|
248
|
+
spriteName,
|
|
249
|
+
["bash", "-c", `cat > /tmp/vaults/${vaultId}.json`],
|
|
250
|
+
{ stdin: json },
|
|
251
|
+
);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`[lifecycle] failed to provision vault ${vaultId}:`, err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Re-export for test seeds and env setup that still use the claude wrapper path
|
|
259
|
+
export { installClaudeWrapper };
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Release and delete the sprite bound to this session. Best-effort — logs
|
|
263
|
+
* failures but does not throw.
|
|
264
|
+
*/
|
|
265
|
+
export async function releaseSession(sessionId: string): Promise<void> {
|
|
266
|
+
const entry = pool.unregister(sessionId);
|
|
267
|
+
const row = getSessionRow(sessionId);
|
|
268
|
+
const name = entry?.spriteName ?? row?.sprite_name ?? null;
|
|
269
|
+
if (name) {
|
|
270
|
+
// Resolve the provider from the session's environment config
|
|
271
|
+
const envObj = row ? getEnvironment(row.environment_id) : null;
|
|
272
|
+
const provider = await resolveContainerProvider(envObj?.config?.provider);
|
|
273
|
+
await provider.delete(name).catch((err: unknown) => {
|
|
274
|
+
console.warn(`releaseSession: failed to delete container ${name}:`, err);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (row?.sprite_name) setSessionSprite(sessionId, null);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reconcile orphaned sprites. Compares sprites.dev's fleet against our
|
|
282
|
+
* sessions table and deletes anything with our prefix that has no active
|
|
283
|
+
* session. Run on startup and on a 1h interval.
|
|
284
|
+
*/
|
|
285
|
+
export async function reconcileOrphans(): Promise<{ deleted: number; kept: number }> {
|
|
286
|
+
let deleted = 0;
|
|
287
|
+
let kept = 0;
|
|
288
|
+
|
|
289
|
+
const liveNames = new Set(
|
|
290
|
+
pool.allSessionSprites().map((e) => e.spriteName).filter(Boolean),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
let token: string | undefined;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const res = await listSprites({
|
|
296
|
+
prefix: SPRITE_NAME_PREFIX,
|
|
297
|
+
max_results: 100,
|
|
298
|
+
continuation_token: token,
|
|
299
|
+
});
|
|
300
|
+
for (const s of res.sprites) {
|
|
301
|
+
if (liveNames.has(s.name)) {
|
|
302
|
+
kept++;
|
|
303
|
+
} else {
|
|
304
|
+
await deleteSprite(s.name).catch(() => {});
|
|
305
|
+
deleted++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!res.has_more) break;
|
|
309
|
+
token = res.next_continuation_token ?? undefined;
|
|
310
|
+
if (!token) break;
|
|
311
|
+
}
|
|
312
|
+
return { deleted, kept };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Reconcile orphaned Docker containers. Same logic as reconcileOrphans
|
|
317
|
+
* but uses the Docker provider's list method (`docker ps --filter`).
|
|
318
|
+
* Run alongside reconcileOrphans on startup and on a 1h interval.
|
|
319
|
+
*/
|
|
320
|
+
export async function reconcileDockerOrphans(): Promise<{ deleted: number; kept: number }> {
|
|
321
|
+
let deleted = 0;
|
|
322
|
+
let kept = 0;
|
|
323
|
+
|
|
324
|
+
const liveNames = new Set(
|
|
325
|
+
pool.allSessionSprites().map((e) => e.spriteName).filter(Boolean),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const containers = await dockerProvider.list({ prefix: SPRITE_NAME_PREFIX });
|
|
329
|
+
for (const c of containers) {
|
|
330
|
+
if (liveNames.has(c.name)) {
|
|
331
|
+
kept++;
|
|
332
|
+
} else {
|
|
333
|
+
await dockerProvider.delete(c.name).catch(() => {});
|
|
334
|
+
deleted++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { deleted, kept };
|
|
338
|
+
}
|
|
339
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-environment sprite pool + session-to-sprite affinity.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are pinned 1:1 to a sprite for their entire lifetime. The pool
|
|
5
|
+
* tracks which sprites are attached to which sessions so we can enforce
|
|
6
|
+
* `max_sprites_per_env` and clean up on session delete.
|
|
7
|
+
*
|
|
8
|
+
* Pattern inspired by
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
export interface SpriteEntry {
|
|
12
|
+
spriteName: string;
|
|
13
|
+
envId: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PoolState = {
|
|
19
|
+
byEnv: Map<string, SpriteEntry[]>;
|
|
20
|
+
bySession: Map<string, SpriteEntry>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type GlobalPool = typeof globalThis & { __caPool?: PoolState };
|
|
24
|
+
const g = globalThis as GlobalPool;
|
|
25
|
+
|
|
26
|
+
function state(): PoolState {
|
|
27
|
+
if (!g.__caPool) {
|
|
28
|
+
g.__caPool = { byEnv: new Map(), bySession: new Map() };
|
|
29
|
+
}
|
|
30
|
+
return g.__caPool;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function register(entry: SpriteEntry): void {
|
|
34
|
+
const s = state();
|
|
35
|
+
const list = s.byEnv.get(entry.envId) ?? [];
|
|
36
|
+
list.push(entry);
|
|
37
|
+
s.byEnv.set(entry.envId, list);
|
|
38
|
+
s.bySession.set(entry.sessionId, entry);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getBySession(sessionId: string): SpriteEntry | null {
|
|
42
|
+
return state().bySession.get(sessionId) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function countInEnv(envId: string): number {
|
|
46
|
+
return state().byEnv.get(envId)?.length ?? 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unregister(sessionId: string): SpriteEntry | null {
|
|
50
|
+
const s = state();
|
|
51
|
+
const entry = s.bySession.get(sessionId);
|
|
52
|
+
if (!entry) return null;
|
|
53
|
+
s.bySession.delete(sessionId);
|
|
54
|
+
const list = s.byEnv.get(entry.envId);
|
|
55
|
+
if (list) {
|
|
56
|
+
const idx = list.findIndex((e) => e.sessionId === sessionId);
|
|
57
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
58
|
+
if (list.length === 0) s.byEnv.delete(entry.envId);
|
|
59
|
+
}
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function allSessionSprites(): SpriteEntry[] {
|
|
64
|
+
return Array.from(state().bySession.values());
|
|
65
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async environment setup: package install + checkpoint.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the background after `POST /v1/environments` returns. Creates a
|
|
5
|
+
* fresh template sprite, installs packages per `env.config.packages`, writes
|
|
6
|
+
* an idempotency sentinel, creates a sprites.dev checkpoint, persists the
|
|
7
|
+
* checkpoint id on the environment row, deletes the template sprite, and
|
|
8
|
+
* flips `environments.state` to `ready` (or `failed` on error).
|
|
9
|
+
*
|
|
10
|
+
* Sentinel idempotency pattern from
|
|
11
|
+
*
|
|
12
|
+
*
|
|
13
|
+
* NOTE: the sentinel path assumes claude runs as user `sprite` with
|
|
14
|
+
* `HOME=/home/sprite`. Spike S1 validates this and may change the path.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import {
|
|
18
|
+
createSprite,
|
|
19
|
+
deleteSprite,
|
|
20
|
+
httpExec,
|
|
21
|
+
createCheckpoint,
|
|
22
|
+
} from "./client";
|
|
23
|
+
import {
|
|
24
|
+
CLAUDE_WRAPPER_PATH as WRAPPER_PATH,
|
|
25
|
+
installClaudeWrapper as installWrapper,
|
|
26
|
+
} from "../backends/claude/wrapper-script";
|
|
27
|
+
import {
|
|
28
|
+
getEnvironmentRow,
|
|
29
|
+
updateEnvironmentCheckpoint,
|
|
30
|
+
updateEnvironmentState,
|
|
31
|
+
} from "../db/environments";
|
|
32
|
+
import type { EnvironmentConfig } from "../types";
|
|
33
|
+
import type { ContainerProvider } from "../providers/types";
|
|
34
|
+
import { resolveContainerProvider } from "../providers/registry";
|
|
35
|
+
import { newId } from "../util/ids";
|
|
36
|
+
|
|
37
|
+
const SENTINEL_DIR = "/home/sprite";
|
|
38
|
+
|
|
39
|
+
function hashPackages(packages: EnvironmentConfig["packages"]): string {
|
|
40
|
+
const canonical = JSON.stringify(packages ?? {}, Object.keys(packages ?? {}).sort());
|
|
41
|
+
return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildInstallCommands(packages: EnvironmentConfig["packages"]): string[] {
|
|
45
|
+
if (!packages) return [];
|
|
46
|
+
const cmds: string[] = [];
|
|
47
|
+
// Alphabetical order to match Managed Agents spec behavior.
|
|
48
|
+
if (packages.apt?.length) {
|
|
49
|
+
cmds.push(`apt-get update -qq && apt-get install -y -qq ${packages.apt.map(shellEscape).join(" ")}`);
|
|
50
|
+
}
|
|
51
|
+
if (packages.cargo?.length) {
|
|
52
|
+
cmds.push(`cargo install ${packages.cargo.map(shellEscape).join(" ")}`);
|
|
53
|
+
}
|
|
54
|
+
if (packages.gem?.length) {
|
|
55
|
+
cmds.push(`gem install ${packages.gem.map(shellEscape).join(" ")}`);
|
|
56
|
+
}
|
|
57
|
+
if (packages.go?.length) {
|
|
58
|
+
for (const pkg of packages.go) cmds.push(`go install ${shellEscape(pkg)}`);
|
|
59
|
+
}
|
|
60
|
+
if (packages.npm?.length) {
|
|
61
|
+
cmds.push(`npm install -g ${packages.npm.map(shellEscape).join(" ")}`);
|
|
62
|
+
}
|
|
63
|
+
if (packages.pip?.length) {
|
|
64
|
+
cmds.push(`pip install ${packages.pip.map(shellEscape).join(" ")}`);
|
|
65
|
+
}
|
|
66
|
+
return cmds;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shellEscape(s: string): string {
|
|
70
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Prepare a freshly-created sprite: install the wrapper script and run
|
|
75
|
+
* the environment's setup commands. Returns when the sentinel is in place.
|
|
76
|
+
*/
|
|
77
|
+
export async function prepareSprite(
|
|
78
|
+
spriteName: string,
|
|
79
|
+
packages: EnvironmentConfig["packages"],
|
|
80
|
+
provider?: ContainerProvider,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const p = provider ?? await resolveContainerProvider();
|
|
83
|
+
await installWrapper(spriteName, p);
|
|
84
|
+
|
|
85
|
+
const hash = hashPackages(packages);
|
|
86
|
+
const sentinel = `${SENTINEL_DIR}/.claude-agents-setup-${hash}`;
|
|
87
|
+
const installCmds = buildInstallCommands(packages);
|
|
88
|
+
if (installCmds.length === 0) {
|
|
89
|
+
await p.exec(spriteName, ["bash", "-c", `touch ${sentinel}`]);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const script = [
|
|
94
|
+
"set -euo pipefail",
|
|
95
|
+
`if [ -f ${sentinel} ]; then exit 0; fi`,
|
|
96
|
+
...installCmds,
|
|
97
|
+
`touch ${sentinel}`,
|
|
98
|
+
].join(" && ");
|
|
99
|
+
|
|
100
|
+
const result = await p.exec(spriteName, ["bash", "-c", script], {
|
|
101
|
+
timeoutMs: 30 * 60_000,
|
|
102
|
+
});
|
|
103
|
+
if (result.exit_code !== 0) {
|
|
104
|
+
throw new Error(`setup failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Kick off env setup in the background. Called from `POST /v1/environments`
|
|
110
|
+
* after the response has been sent.
|
|
111
|
+
*/
|
|
112
|
+
export function kickoffEnvironmentSetup(envId: string): void {
|
|
113
|
+
// Fire-and-forget. Errors are captured onto the environment row.
|
|
114
|
+
runEnvironmentSetup(envId).catch((err: unknown) => {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
console.error(`[env ${envId}] setup failed:`, msg);
|
|
117
|
+
try {
|
|
118
|
+
updateEnvironmentState(envId, "failed", msg);
|
|
119
|
+
} catch (dbErr) {
|
|
120
|
+
console.error(`[env ${envId}] failed to record setup failure:`, dbErr);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runEnvironmentSetup(envId: string): Promise<void> {
|
|
126
|
+
const row = getEnvironmentRow(envId);
|
|
127
|
+
if (!row) throw new Error(`environment ${envId} not found`);
|
|
128
|
+
if (row.state === "ready") return;
|
|
129
|
+
|
|
130
|
+
const config = JSON.parse(row.config_json) as EnvironmentConfig;
|
|
131
|
+
const provider = await resolveContainerProvider(config.provider);
|
|
132
|
+
const hasPackages = config.packages && Object.values(config.packages).some((v) => v && v.length > 0);
|
|
133
|
+
|
|
134
|
+
if (!hasPackages) {
|
|
135
|
+
// No packages to install — env is ready immediately. Each session sprite
|
|
136
|
+
// will get the wrapper installed in lifecycle.acquireForFirstTurn().
|
|
137
|
+
updateEnvironmentState(envId, "ready", null);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// With packages: create a template container, run installs, then mark ready.
|
|
142
|
+
// NOTE: sprites.dev checkpoints are per-sprite only, so we can't snapshot
|
|
143
|
+
// the template and restore onto session sprites. For now, packages are
|
|
144
|
+
// re-installed per session sprite (slow). M5 will optimize with a sprite
|
|
145
|
+
// pool or alternative approach.
|
|
146
|
+
const templateName = `ca-env-tpl-${newId("env").slice(4, 16).toLowerCase()}`;
|
|
147
|
+
await provider.create({ name: templateName });
|
|
148
|
+
try {
|
|
149
|
+
await prepareSprite(templateName, config.packages, provider);
|
|
150
|
+
// Record that setup succeeded but no checkpoint to share.
|
|
151
|
+
updateEnvironmentState(envId, "ready", null);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw err;
|
|
154
|
+
} finally {
|
|
155
|
+
await provider.delete(templateName).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { WRAPPER_PATH, SENTINEL_DIR };
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global mutable runtime state — HMR-safe via globalThis so Next.js dev
|
|
3
|
+
* reloads don't duplicate maps.
|
|
4
|
+
*
|
|
5
|
+
* Pattern from
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface InFlightRun {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
controller: AbortController;
|
|
12
|
+
startedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TurnInput =
|
|
16
|
+
| { kind: "text"; eventId: string; text: string }
|
|
17
|
+
| {
|
|
18
|
+
kind: "tool_result";
|
|
19
|
+
eventId: string;
|
|
20
|
+
custom_tool_use_id: string;
|
|
21
|
+
content: unknown[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface PendingUserInput {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
input: TurnInput;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type RuntimeState = {
|
|
30
|
+
inFlightRuns: Map<string, InFlightRun>;
|
|
31
|
+
pendingUserInputs: Map<string, TurnInput[]>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type GlobalState = typeof globalThis & {
|
|
35
|
+
__caRuntime?: RuntimeState;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function getRuntime(): RuntimeState {
|
|
39
|
+
const g = globalThis as GlobalState;
|
|
40
|
+
if (!g.__caRuntime) {
|
|
41
|
+
g.__caRuntime = {
|
|
42
|
+
inFlightRuns: new Map(),
|
|
43
|
+
pendingUserInputs: new Map(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return g.__caRuntime;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pushPendingUserInput(input: PendingUserInput): void {
|
|
50
|
+
const rt = getRuntime();
|
|
51
|
+
const list = rt.pendingUserInputs.get(input.sessionId) ?? [];
|
|
52
|
+
list.push(input.input);
|
|
53
|
+
rt.pendingUserInputs.set(input.sessionId, list);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function drainPendingUserInputs(sessionId: string): TurnInput[] {
|
|
57
|
+
const rt = getRuntime();
|
|
58
|
+
const list = rt.pendingUserInputs.get(sessionId) ?? [];
|
|
59
|
+
rt.pendingUserInputs.delete(sessionId);
|
|
60
|
+
return list;
|
|
61
|
+
}
|