Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ async function adminWrite(request, response, path) {
const index = collection.findIndex((entry) => entry.id === resourceId && a.owner.get(`${kind.slice(0, -1)}:${entry.id}`) === project.id);
if (index < 0) return error(response, 404, "No such resource.");
collection.splice(index, 1);
if (kind === "sessions") for (const allocation of state.allocations) if (allocation.session_id === resourceId) allocation.session_id = null;
return send(response, 200, { id: resourceId, object: kind === "agents" ? "agent.deleted" : "agent.session.deleted", deleted: true });
}
return error(response, 404, "Not found.");
Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/sandbox-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ test("unknown target preparation preserves live old-generation service while an
test("a generation-only change preserves mixed allocation ownership through failed inventory reads", async ({ page, request }) => {
await openConsole(page, request, "settings?id=sandbox");
await expect(rollout(page)).toBeVisible();
let expected: { session: string; generation: string }[] = [];
let expected: { session: string | null; generation: string }[] = [];
await page.route("**/core/v1/sandbox/nodes/node-local/allocations", async (route) => {
const response = await route.fetch();
const body = await response.json() as { data: SandboxAllocation[] };
Expand All @@ -158,7 +158,7 @@ test("a generation-only change preserves mixed allocation ownership through fail
await expect(table.getByRole("columnheader", { name: "Configuration generation", exact: true })).toBeVisible();
await expect.poll(() => [...new Set(expected.map((entry) => entry.generation))].sort()).toEqual(["1", "2"]);
const ownership = () => table.locator("tbody tr").evaluateAll((rows) => rows.map((row) => ({
session: row.querySelector("th code")?.getAttribute("title"),
session: row.querySelector("th code")?.getAttribute("title") ?? null,
generation: row.querySelector("td")?.textContent,
})));
await expect.poll(ownership).toEqual(expected);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/sandbox/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
<tbody>
{own.map((allocation) => (
<tr key={allocation.id}>
<th scope="row"><CopyableId id={allocation.session_id} label={t("Session")} /></th>
<th scope="row">{allocation.session_id ? <CopyableId id={allocation.session_id} label={t("Session")} /> : MISSING}</th>
<td>{allocation.deployment_generation}</td>
<td>{sandboxStateLabel(allocation.state, shortLocale)}</td>
<td>{allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING}</td>
Expand Down
2 changes: 2 additions & 0 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1412,7 +1412,9 @@ definitions:
node_id:
type: string
session_id:
description: The live Session attached to the Environment, or null when none is.
type: string
x-nullable: true
state:
type: string
tenant_id:
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/sandbox-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ A node manages generations, so its `provider_ready` follows its serving generati

`PATCH /core/v1/sandbox/nodes/{node_id}` takes `{name, max_active, max_retained}`; lowering a limit stops no running sandbox. `DELETE /core/v1/sandbox/nodes/{node_id}` refuses with 409 `runtime_node_in_use` while the node holds allocations, snapshots, reservations or pending cleanup, including while it is offline. Removal deletes no compute and retires the node's identity; the host can come back only as a new node. There is no node drain.

`GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `compute_phase_changed_at` is when the allocation entered its current `compute_phase`, or null when unknown. This is a phase transition time, not a snapshot expiry. The snapshot deadline is not exposed by this response and must not be inferred from `suspension.retention_seconds`.
`GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `session_id` is the live Session attached to the allocation's Environment, or null when none is attached. Each item's `compute_phase_changed_at` is when the allocation entered its current `compute_phase`, or null when unknown. This is a phase transition time, not a snapshot expiry. The snapshot deadline is not exposed by this response and must not be inferred from `suspension.retention_seconds`.

### Recommended capacity

Expand Down
4 changes: 2 additions & 2 deletions contracts/agents-api/zh/sandbox-deployment.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "沙箱部署"
source: contracts/agents-api/sandbox-deployment.md
source_hash: 9ae9f467b9e62291dcb31e3b5a8c1c64c21ef3f790ad804c85a2939119c0a218
source_hash: c7b69e705061c6c0ee95d9a89d93f82e20304e3f4f12b5ed0442614950867806
---

沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
Expand Down Expand Up @@ -229,7 +229,7 @@ stateDiagram-v2

`PATCH /core/v1/sandbox/nodes/{node_id}` 接受 `{name, max_active, max_retained}`;降低限制不会停止任何正在运行的沙箱。当节点仍持有分配、快照、预留或待处理清理时,包括节点离线期间,`DELETE /core/v1/sandbox/nodes/{node_id}` 会拒绝操作并返回 409 `runtime_node_in_use`。移除操作不会删除任何计算资源,并且会停用该节点的身份;该主机只能作为新节点重新加入。不存在节点排空过程。

`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。
`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `session_id` 是该分配的 Environment 当前挂载的存活 Session;没有挂载时为 null。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。

### 推荐容量 {#recommended-capacity}

Expand Down
2 changes: 1 addition & 1 deletion packages/agents-client/src/generated/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ export interface NodeAllocation {
id: string;
initialization: string;
node_id: string;
session_id: string;
session_id: string | null;
state: string;
tenant_id: string;
}
Expand Down
1 change: 1 addition & 0 deletions packages/agents-client/src/sandbox-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ describe("strict sandbox administration projections", () => {
["nodes", "ready and unready", { data: [node, unready] }], ["nodes", "empty", { data: [] }],
["detail", "observed", detail], ["unobserved", "never observed", unobserved],
["allocations", "known and unknown phase times", { data: [allocation, { ...allocation, compute_phase: "suspended", compute_phase_changed_at: created, diagnostic: "node_unavailable" }] }],
["allocations", "no attached Session", { data: [{ ...allocation, session_id: null }] }],
["deployment", "unconfigured", unconfigured], ["deployment", "microsandbox", microsandbox], ["deployment", "external workspace", externalDeployment], ["deployment", "E2B", e2bDeployment],
])("accepts %s as Core serializes it: %s", async (name, _, body) => {
expect(await read(name, body)).toEqual(body);
Expand Down
4 changes: 2 additions & 2 deletions packages/agents-client/src/sandbox-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,14 @@ function projectNodeDetail(value: unknown, nodeId: string): SandboxNodeDetail {
return { ...node, host: { ...observed } as unknown as NodeHost, history: { resolution_seconds: buckets.resolution_seconds as number, points } };
}

const allocationStrings = ["id", "node_id", "tenant_id", "session_id", "environment_id", "state", "compute_phase", "initialization", "diagnostic"];
const allocationStrings = ["id", "node_id", "tenant_id", "environment_id", "state", "compute_phase", "initialization", "diagnostic"];
function projectAllocations(value: unknown, nodeId: string): { data: SandboxAllocation[] } {
const list = members(value, sandboxAllocationListFields);
valid(Array.isArray(list.data));
return { data: (list.data as unknown[]).map((entry) => {
const allocation = members(entry, nodeAllocationFields);
valid(isNonnegativeInteger(allocation.deployment_generation) && strings(allocation, allocationStrings) && sameResourceId(allocation.node_id as string, nodeId) && isOneOf(nodeAllocationDiagnosticValues, allocation.diagnostic) &&
nullable(timestamp)(allocation.compute_phase_changed_at) && timestamp(allocation.created_at));
nullable(timestamp)(allocation.compute_phase_changed_at) && (allocation.session_id === null || typeof allocation.session_id === "string") && timestamp(allocation.created_at));
return { ...allocation } as unknown as SandboxAllocation;
}) };
}
Expand Down
9 changes: 5 additions & 4 deletions services/core/internal/deployment/allocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,11 @@ type NodeAllocation struct {
ID string `json:"id" binding:"required"`
NodeID string `json:"node_id" binding:"required"`
TenantID string `json:"tenant_id" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
EnvironmentID string `json:"environment_id" binding:"required"`
State string `json:"state" binding:"required"`
ComputePhase string `json:"compute_phase" binding:"required"`
// The live Session attached to the Environment, or null when none is.
SessionID *string `json:"session_id" extensions:"x-nullable" binding:"required"`
EnvironmentID string `json:"environment_id" binding:"required"`
State string `json:"state" binding:"required"`
ComputePhase string `json:"compute_phase" binding:"required"`
// The time the allocation entered its current compute_phase, or null when unknown; an allocation that existed before Core recorded it reports null until its next phase change. For a suspended allocation, this time plus the deployment's snapshot retention tells roughly when Core reclaims it.
ComputePhaseChangedAt *time.Time `json:"compute_phase_changed_at" extensions:"x-nullable" binding:"required"`
Initialization string `json:"initialization" binding:"required"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,13 @@ func (s *Store) NodeAllocations(ctx context.Context, nodeID string) ([]deploymen
}
result := make([]deployment.NodeAllocation, 0, len(rows))
for _, a := range rows {
var session *string
if a.AttachedSessionID.Valid {
session = new(uuidString(a.AttachedSessionID))
}
result = append(result, deployment.NodeAllocation{
DeploymentGeneration: uint64(a.DeploymentGeneration.Int64), Diagnostic: a.ObservationError, ID: uuidString(a.ID), NodeID: uuidString(a.NodeID),
TenantID: uuidString(a.TenantID), SessionID: uuidString(a.AttachedSessionID), EnvironmentID: uuidString(a.EnvironmentID), State: a.State,
TenantID: uuidString(a.TenantID), SessionID: session, EnvironmentID: uuidString(a.EnvironmentID), State: a.State,
ComputePhase: a.ComputePhase, ComputePhaseChangedAt: timestamp(a.ComputePhaseChangedAt), Initialization: a.Initialization, CreatedAt: a.CreatedAt.Time,
})
}
Expand Down
9 changes: 8 additions & 1 deletion services/core/tests/integration/runtime_suspension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ func TestRuntimeComputePhaseChangedAt(t *testing.T) {
}
}

// The node allocation list reports the phase time, and null when it is unknown.
// The node allocation list reports the attached Session and the phase time, each null when absent.
func TestRuntimeComputePhaseChangedAtInNodeAllocations(t *testing.T) {
s, w, d := managerFixture(t, 1, 4)
tenant := uuid.NewString()
Expand Down Expand Up @@ -427,6 +427,13 @@ func TestRuntimeComputePhaseChangedAtInNodeAllocations(t *testing.T) {
if encoded, _ := json.Marshal(listed()); !strings.Contains(string(encoded), `"compute_phase_changed_at":null`) {
t.Fatal("an unknown phase time was not null", string(encoded))
}
if got := listed().SessionID; got == nil || *got != session.ID {
t.Fatal("the attached Session was not reported", got)
}
runtimeSuspensionSQL(t, s.pool, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID)
if encoded, _ := json.Marshal(listed()); !strings.Contains(string(encoded), `"session_id":null`) {
t.Fatal("a deleted Session was reported", string(encoded))
}
}

func TestNeverStartedSessionIdleStartsAfterInitialization(t *testing.T) {
Expand Down
Loading