Skip to content

Commit b402551

Browse files
committed
sdk/typescript: Add sandbox lifecycle (execution & session)
1 parent 8f75c48 commit b402551

File tree

26 files changed

+1622
-105
lines changed

26 files changed

+1622
-105
lines changed

orchestrator/src/api/workers/handlers.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,56 @@ pub async fn get_worker_status(
775775
}))
776776
}
777777

778+
/// Response for active workers query
779+
#[derive(Serialize, ToSchema)]
780+
pub struct ActiveWorkersResponse {
781+
pub worker_ids: Vec<String>,
782+
}
783+
784+
/// Get active worker IDs for a project.
785+
/// Returns workers that are online and have sent a heartbeat within the last 60 seconds.
786+
#[utoipa::path(
787+
get,
788+
path = "/api/v1/workers/active",
789+
tag = "Workers",
790+
params(
791+
("X-Project-ID" = String, Header, description = "Project ID")
792+
),
793+
responses(
794+
(status = 200, description = "Active worker IDs", body = ActiveWorkersResponse),
795+
(status = 400, description = "Bad request"),
796+
(status = 500, description = "Internal server error")
797+
),
798+
security(
799+
("bearer_auth" = []),
800+
("cookie_auth" = [])
801+
)
802+
)]
803+
pub async fn get_active_workers(
804+
State(state): State<Arc<AppState>>,
805+
ProjectId(project_id): ProjectId,
806+
) -> Result<Json<ActiveWorkersResponse>, StatusCode> {
807+
state
808+
.db
809+
.set_project_id(&project_id, false)
810+
.await
811+
.map_err(|e| {
812+
tracing::error!("Failed to set project_id: {}", e);
813+
StatusCode::INTERNAL_SERVER_ERROR
814+
})?;
815+
816+
let worker_ids = state
817+
.db
818+
.get_active_worker_ids(&project_id)
819+
.await
820+
.map_err(|e| {
821+
tracing::error!("Failed to get active worker IDs: {}", e);
822+
StatusCode::INTERNAL_SERVER_ERROR
823+
})?;
824+
825+
Ok(Json(ActiveWorkersResponse { worker_ids }))
826+
}
827+
778828
// Mark worker as online (called after worker completes registration of agents, tools, workflows, etc.)
779829
pub async fn mark_worker_online(
780830
State(state): State<Arc<AppState>>,

orchestrator/src/db/workers.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ use crate::db::{
88
};
99

1010
impl Database {
11+
/// Get IDs of all active workers for a project.
12+
/// A worker is considered active if it is online and has sent a heartbeat
13+
/// within the last 60 seconds.
14+
pub async fn get_active_worker_ids(&self, project_id: &Uuid) -> anyhow::Result<Vec<String>> {
15+
let rows: Vec<(Uuid,)> = sqlx::query_as(
16+
"SELECT id FROM workers
17+
WHERE project_id = $1
18+
AND status = 'online'
19+
AND last_heartbeat > NOW() - INTERVAL '60 seconds'",
20+
)
21+
.bind(project_id)
22+
.fetch_all(&self.pool)
23+
.await?;
24+
25+
Ok(rows.into_iter().map(|(id,)| id.to_string()).collect())
26+
}
27+
1128
// Get count of online workers for a deployment
1229
pub async fn get_worker_count_for_deployment(
1330
&self,

orchestrator/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ pub struct AppState {
8181
api::workers::handlers::register_worker,
8282
api::workers::handlers::register_worker_deployment,
8383
api::workers::handlers::get_worker_status,
84+
api::workers::handlers::get_active_workers,
8485
// Deployments
8586
api::deployments::handlers::register_deployment_workflow,
8687
api::deployments::handlers::get_deployment,
@@ -133,6 +134,7 @@ pub struct AppState {
133134
api::workers::handlers::RegisterWorkerDeploymentRequest,
134135
api::workers::handlers::RegisterWorkerResponse,
135136
api::workers::handlers::WorkerStatusResponse,
137+
api::workers::handlers::ActiveWorkersResponse,
136138
// Deployments
137139
api::deployments::handlers::RegisterDeploymentWorkflowRequest,
138140
)
@@ -841,6 +843,10 @@ async fn main() -> anyhow::Result<()> {
841843
get(api::deployments::get_deployment),
842844
)
843845
// Worker endpoints
846+
.route(
847+
"/api/v1/workers/active",
848+
get(api::workers::get_active_workers),
849+
)
844850
.route(
845851
"/api/v1/workers/status",
846852
get(api::workers::get_worker_status),
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { describe, it, beforeEach } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import { SandboxManager, parseDuration } from '../sandbox-manager.js';
5+
import type { SandboxConfig } from '../types.js';
6+
7+
describe('parseDuration', () => {
8+
it('parses minutes', () => {
9+
assert.strictEqual(parseDuration('30m'), 30 * 60 * 1000);
10+
});
11+
12+
it('parses hours', () => {
13+
assert.strictEqual(parseDuration('1h'), 60 * 60 * 1000);
14+
assert.strictEqual(parseDuration('24h'), 24 * 60 * 60 * 1000);
15+
});
16+
17+
it('parses days', () => {
18+
assert.strictEqual(parseDuration('3d'), 3 * 24 * 60 * 60 * 1000);
19+
});
20+
21+
it('parses fractional values', () => {
22+
assert.strictEqual(parseDuration('0.5h'), 0.5 * 60 * 60 * 1000);
23+
});
24+
25+
it('trims whitespace', () => {
26+
assert.strictEqual(parseDuration(' 1h '), 60 * 60 * 1000);
27+
});
28+
29+
it('throws for invalid format', () => {
30+
assert.throws(() => parseDuration('abc'), /Invalid duration/);
31+
assert.throws(() => parseDuration('1x'), /Invalid duration/);
32+
assert.throws(() => parseDuration(''), /Invalid duration/);
33+
assert.throws(() => parseDuration('1'), /Invalid duration/);
34+
});
35+
});
36+
37+
describe('SandboxManager', () => {
38+
let manager: SandboxManager;
39+
40+
const localConfig: SandboxConfig = {
41+
env: 'local',
42+
local: { cwd: '/tmp' },
43+
};
44+
45+
const sessionConfig: SandboxConfig = {
46+
...localConfig,
47+
scope: 'session',
48+
};
49+
50+
beforeEach(() => {
51+
manager = new SandboxManager('worker-1', 'test-project');
52+
});
53+
54+
describe('setWorkerId', () => {
55+
it('updates the worker ID', () => {
56+
manager.setWorkerId('worker-2');
57+
// Verify by creating a sandbox (workerId propagates to sandbox)
58+
const sandbox = manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
59+
assert.ok(sandbox);
60+
});
61+
});
62+
63+
describe('getOrCreateSandbox — execution scope', () => {
64+
it('creates a new sandbox for each call', async () => {
65+
const s1 = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
66+
const s2 = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-2' });
67+
68+
assert.notStrictEqual(s1.id, s2.id);
69+
});
70+
71+
it('attaches execution to sandbox', async () => {
72+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
73+
assert.ok(sandbox.activeExecutionIds.has('exec-1'));
74+
});
75+
76+
it('sandbox is retrievable by ID', async () => {
77+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
78+
const found = manager.getSandbox(sandbox.id);
79+
assert.strictEqual(found, sandbox);
80+
});
81+
82+
it('defaults scope to execution', async () => {
83+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
84+
assert.strictEqual(sandbox.scope, 'execution');
85+
});
86+
});
87+
88+
describe('getOrCreateSandbox — session scope', () => {
89+
it('throws when sessionId is missing', async () => {
90+
await assert.rejects(
91+
() => manager.getOrCreateSandbox(sessionConfig, { executionId: 'exec-1' }),
92+
/sessionId is required/
93+
);
94+
});
95+
96+
it('creates a session sandbox', async () => {
97+
const sandbox = await manager.getOrCreateSandbox(sessionConfig, {
98+
executionId: 'exec-1',
99+
sessionId: 'sess-1',
100+
});
101+
102+
assert.strictEqual(sandbox.scope, 'session');
103+
assert.ok(sandbox.activeExecutionIds.has('exec-1'));
104+
});
105+
106+
it('reuses existing session sandbox', async () => {
107+
const s1 = await manager.getOrCreateSandbox(sessionConfig, {
108+
executionId: 'exec-1',
109+
sessionId: 'sess-1',
110+
});
111+
const s2 = await manager.getOrCreateSandbox(sessionConfig, {
112+
executionId: 'exec-2',
113+
sessionId: 'sess-1',
114+
});
115+
116+
assert.strictEqual(s1.id, s2.id);
117+
assert.ok(s2.activeExecutionIds.has('exec-1'));
118+
assert.ok(s2.activeExecutionIds.has('exec-2'));
119+
});
120+
121+
it('creates new sandbox for different sessions', async () => {
122+
const s1 = await manager.getOrCreateSandbox(sessionConfig, {
123+
executionId: 'exec-1',
124+
sessionId: 'sess-1',
125+
});
126+
const s2 = await manager.getOrCreateSandbox(sessionConfig, {
127+
executionId: 'exec-2',
128+
sessionId: 'sess-2',
129+
});
130+
131+
assert.notStrictEqual(s1.id, s2.id);
132+
});
133+
134+
it('is retrievable by session ID', async () => {
135+
const sandbox = await manager.getOrCreateSandbox(sessionConfig, {
136+
executionId: 'exec-1',
137+
sessionId: 'sess-1',
138+
});
139+
const found = manager.getSessionSandbox('sess-1');
140+
assert.strictEqual(found, sandbox);
141+
});
142+
});
143+
144+
describe('onExecutionComplete', () => {
145+
it('detaches execution from sandbox', async () => {
146+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
147+
assert.ok(sandbox.activeExecutionIds.has('exec-1'));
148+
149+
await manager.onExecutionComplete('exec-1');
150+
assert.ok(!sandbox.activeExecutionIds.has('exec-1'));
151+
});
152+
153+
it('destroys execution-scoped sandbox on completion', async () => {
154+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
155+
const sandboxId = sandbox.id;
156+
157+
await manager.onExecutionComplete('exec-1');
158+
159+
// Sandbox should be removed from manager
160+
assert.strictEqual(manager.getSandbox(sandboxId), undefined);
161+
assert.strictEqual(sandbox.destroyed, true);
162+
});
163+
164+
it('does not destroy session-scoped sandbox on execution complete', async () => {
165+
const sandbox = await manager.getOrCreateSandbox(sessionConfig, {
166+
executionId: 'exec-1',
167+
sessionId: 'sess-1',
168+
});
169+
170+
await manager.onExecutionComplete('exec-1');
171+
172+
assert.strictEqual(sandbox.destroyed, false);
173+
assert.ok(manager.getSandbox(sandbox.id));
174+
assert.ok(manager.getSessionSandbox('sess-1'));
175+
});
176+
177+
it('is safe for unknown execution ID', async () => {
178+
await manager.onExecutionComplete('nonexistent');
179+
// Should not throw
180+
});
181+
});
182+
183+
describe('destroySandbox', () => {
184+
it('destroys and removes a sandbox', async () => {
185+
const sandbox = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
186+
const sandboxId = sandbox.id;
187+
188+
await manager.destroySandbox(sandboxId);
189+
190+
assert.strictEqual(sandbox.destroyed, true);
191+
assert.strictEqual(manager.getSandbox(sandboxId), undefined);
192+
});
193+
194+
it('removes session index for session-scoped sandbox', async () => {
195+
const sandbox = await manager.getOrCreateSandbox(sessionConfig, {
196+
executionId: 'exec-1',
197+
sessionId: 'sess-1',
198+
});
199+
200+
await manager.destroySandbox(sandbox.id);
201+
202+
assert.strictEqual(manager.getSessionSandbox('sess-1'), undefined);
203+
});
204+
205+
it('is safe for unknown sandbox ID', async () => {
206+
await manager.destroySandbox('nonexistent');
207+
// Should not throw
208+
});
209+
});
210+
211+
describe('destroyAll', () => {
212+
it('destroys all sandboxes', async () => {
213+
const s1 = await manager.getOrCreateSandbox(localConfig, { executionId: 'exec-1' });
214+
const s2 = await manager.getOrCreateSandbox(sessionConfig, {
215+
executionId: 'exec-2',
216+
sessionId: 'sess-1',
217+
});
218+
219+
await manager.destroyAll();
220+
221+
assert.strictEqual(s1.destroyed, true);
222+
assert.strictEqual(s2.destroyed, true);
223+
assert.strictEqual(manager.getSandbox(s1.id), undefined);
224+
assert.strictEqual(manager.getSandbox(s2.id), undefined);
225+
assert.strictEqual(manager.getSessionSandbox('sess-1'), undefined);
226+
});
227+
228+
it('is safe when no sandboxes exist', async () => {
229+
await manager.destroyAll();
230+
// Should not throw
231+
});
232+
});
233+
234+
describe('startSweep / stopSweep', () => {
235+
it('starts and stops without error', () => {
236+
manager.startSweep(60000);
237+
manager.stopSweep();
238+
});
239+
240+
it('stop is idempotent', () => {
241+
manager.stopSweep();
242+
manager.stopSweep();
243+
});
244+
245+
it('start replaces existing sweep', () => {
246+
manager.startSweep(60000);
247+
manager.startSweep(60000); // should not throw or leak
248+
manager.stopSweep();
249+
});
250+
});
251+
252+
describe('getSandbox / getSessionSandbox', () => {
253+
it('returns undefined for unknown IDs', () => {
254+
assert.strictEqual(manager.getSandbox('unknown'), undefined);
255+
assert.strictEqual(manager.getSessionSandbox('unknown'), undefined);
256+
});
257+
});
258+
});

0 commit comments

Comments
 (0)