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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"allowedCycles": [],
"maxRootFiles": {
"src/lib/onboard": 309,
"src/lib/onboard": 308,
"src/lib/actions": 19,
"src/lib/actions/sandbox": 183,
"src/lib/state": 38,
Expand Down
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"test/generate-openclaw-config.test.ts": 1915,
"test/install-preflight.test.ts": 3310,
"test/nemoclaw-start.test.ts": 4790,
"test/onboard-messaging.test.ts": 2035,
"test/onboard-messaging.test.ts": 2033,
"test/onboard-selection.test.ts": 4178
}
}
17 changes: 13 additions & 4 deletions src/lib/actions/sandbox/snapshot-restore-test-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,19 @@ export function openshellResponses(
args: string[],
responses: Record<string, OpenshellCaptureResult>,
): OpenshellCaptureResult {
const result = responses[`${args[0] ?? ""} ${args[1] ?? ""}`] ?? {
status: 0,
output: "",
};
const command = `${args[0] ?? ""} ${args[1] ?? ""}`;
const sandboxName = String(args.at(-1) ?? "sandbox");
const result =
responses[command] ??
(command === "sandbox get"
? {
status: 0,
output: `Name: ${sandboxName}\nId: ${sandboxName}-live-id\nPhase: Ready\n`,
}
: {
status: 0,
output: "",
});
return captureOpenshellStreams(args, result);
}

Expand Down
30 changes: 28 additions & 2 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import {
} from "./sandbox-gateway-routing";
import {
backupSandboxStateWithManagedAuthority,
createSnapshotCloneLifecycle,
fingerprintSandboxLiveIdentity,
confirmSandboxRuntimeRestore,
type PreparedSandboxRuntimeRestore,
prepareManagedSnapshotProfileRestore,
Expand Down Expand Up @@ -398,6 +400,26 @@ async function autoCreateSandboxFromSource(
dashboardEnvArgs: readonly string[],
dstHermesApiPort: number | null,
): Promise<void> {
const cloneLifecycle = createSnapshotCloneLifecycle(
dstName,
sourceGatewayName,
(sandboxName, gatewayName) => {
const get = captureOpenshell(["sandbox", "get", "-g", gatewayName, sandboxName], {
ignoreError: true,
});
const list = captureOpenshell(["sandbox", "list", "-g", gatewayName], {
ignoreError: true,
});
return {
state:
get.status === 0 && list.status === 0 && isSandboxReady(list.output || "", sandboxName)
? ("ready" as const)
: ("not_ready" as const),
liveIdentityFingerprint:
get.status === 0 ? fingerprintSandboxLiveIdentity(get.output || "") : null,
};
},
);
const openshellBin = getOpenshellBinary();
const sourceObservabilityEnabled =
(srcEntry as { observabilityEnabled?: boolean }).observabilityEnabled === true;
Expand Down Expand Up @@ -433,7 +455,7 @@ async function autoCreateSandboxFromSource(
initialPhase: "create",
// Wait until the sandbox actually reaches Ready state, not just appears in the list.
readyCheck: () => {
const list = captureOpenshell(["sandbox", "list"], {
const list = captureOpenshell(["sandbox", "list", "-g", sourceGatewayName], {
ignoreError: true,
});
if (list.status !== 0) return false;
Expand All @@ -449,11 +471,14 @@ async function autoCreateSandboxFromSource(
}

// Double-check Ready after stream exit.
const verify = captureOpenshell(["sandbox", "list"], { ignoreError: true });
const verify = captureOpenshell(["sandbox", "list", "-g", sourceGatewayName], {
ignoreError: true,
});
if (verify.status !== 0 || !isSandboxReady(verify.output || "", dstName)) {
console.error(` Sandbox '${dstName}' did not reach Ready state after create.`);
snapshotExit(1);
}
const lifecycleRegistration = cloneLifecycle.capture();

// DNS proxy is only meaningful for the kubernetes driver (matches onboard.ts).
const dnsScript = path.join(ROOT, "scripts", "setup-dns-proxy.sh");
Expand Down Expand Up @@ -496,6 +521,7 @@ async function autoCreateSandboxFromSource(
// stop/start, recovery, and later snapshots can address its gateway.
gatewayName: sourceGatewayName,
gatewayPort: sourceGatewayPort,
...cloneLifecycle.revalidate(lifecycleRegistration),
});

const sourceAgent = (srcEntry as SandboxEntry).agent || "openclaw";
Expand Down
33 changes: 33 additions & 0 deletions src/lib/actions/sandbox/snapshot/clone-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { randomUUID } from "node:crypto";

import {
captureCreatedSandboxLifecycleRegistration,
fingerprintSandboxLiveIdentity,
revalidateCreatedSandboxLifecycleRegistration,
type SandboxRecreateObservation,
} from "../../../onboard/sandbox-recreate-transaction";

export { fingerprintSandboxLiveIdentity };

export function createSnapshotCloneLifecycle(
sandboxName: string,
gatewayName: string,
observe: (sandboxName: string, gatewayName: string) => SandboxRecreateObservation,
) {
const lifecycleGeneration = randomUUID();
const target = { sandboxName, gatewayName };
return {
capture: () =>
captureCreatedSandboxLifecycleRegistration(
target,
lifecycleGeneration,
{ lifecycleGeneration },
observe,
),
revalidate: (registration: ReturnType<typeof captureCreatedSandboxLifecycleRegistration>) =>
revalidateCreatedSandboxLifecycleRegistration(target, registration, observe),
};
}
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/snapshot/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
PrepareManagedWorkloadCloneHandoffInput,
} from "../../../onboard/workload/clone";
export { backupSandboxStateWithManagedAuthority } from "./backup-authority";
export { createSnapshotCloneLifecycle, fingerprintSandboxLiveIdentity } from "./clone-lifecycle";
export type {
ManagedCloneProviderBinding,
ManagedCloneProviderCleanupResult,
Expand Down
11 changes: 5 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,7 @@ async function createSandboxWithBaseImageResolution(
request: managedStartupRootApplyRequest,
intendedWorkloadArgv: intendedSandboxStartupCommand,
});
const createdSandboxLifecycle = sandboxRecreateTransaction.createCreatedSandboxLifecycle(recreateRuntime, { sandboxName, gatewayName: GATEWAY_NAME }, getSandboxRecreateObservation);
const {
createResult,
runtimePatch,
Expand All @@ -2506,7 +2507,7 @@ async function createSandboxWithBaseImageResolution(
createArgv,
sandboxEnv,
sandboxStartupCommand,
lifecycleGeneration: recreateRuntime.targetGeneration,
lifecycleGeneration: createdSandboxLifecycle.generation,
prebuild,
restoreBackupPath,
terminalAgent: agentDefs.isTerminalAgent(agent),
Expand Down Expand Up @@ -2578,7 +2579,7 @@ async function createSandboxWithBaseImageResolution(
resolveSandboxImageTagFromCreateOutput,
});
const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig);
recreateRuntime.recordCreated();
const pinnedLifecycleRegistration = createdSandboxLifecycle.capture(lifecycleRegistrationFields);
finalizeCreatedSandbox(
{
sandboxName,
Expand All @@ -2602,8 +2603,7 @@ async function createSandboxWithBaseImageResolution(
note,
error: console.error,
exitProcess: (code) => process.exit(code),
register: (openclawImagePluginInstalls) =>
sandboxRegistration.registerCreatedSandbox({
register: (openclawImagePluginInstalls) => sandboxRegistration.registerCreatedSandbox({
sandboxName,
inferenceSelection: sandboxRegistration.selection(sandboxName, provider, model, preferredInferenceApi, createIntent?.endpointSource ?? null),
runtimeFields: sandboxRuntimeFields,
Expand All @@ -2624,8 +2624,7 @@ async function createSandboxWithBaseImageResolution(
hermesDashboardState: finalHermesDashboardState,
hermesApiPort: hermesApiPortReservationScope.effectivePort,
dashboardPort: actualDashboardPort,
...lifecycleRegistrationFields,
...recreateRuntime.registrationFields,
...createdSandboxLifecycle.revalidate(pinnedLifecycleRegistration),
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
hostMounts: resolvedCreateIntent.hostMounts,
Expand Down
9 changes: 6 additions & 3 deletions src/lib/onboard/onboard-recreate-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ vi.mock("./gateway-teardown-authority", () => ({
import type { Session } from "../state/onboard-session";
import * as onboardSession from "../state/onboard-session";
import * as registry from "../state/registry";
import { fingerprintSandboxRecreateValue } from "./sandbox-recreate-transaction";
import {
fingerprintOnboardRecreateTargetIntent,
type OnboardRecreateTargetIntent,
Expand Down Expand Up @@ -74,6 +75,8 @@ describe("non-resumed replacement target fingerprint (#7735)", () => {
});

const SANDBOX_ID = "sbx-71c9a4e08b";
const SANDBOX_FINGERPRINT = fingerprintSandboxRecreateValue(SANDBOX_ID);
const REPLACEMENT_FINGERPRINT = fingerprintSandboxRecreateValue("sbx-2f80d5a613");

const NON_DEFAULT_TARGET = {
sandboxName: "alpha",
Expand Down Expand Up @@ -219,7 +222,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => {
runtime.confirmDeleted();
runtime.advance("creating");
mocks.captureOpenshell.mockReturnValue(livePresentProbe());
runtime.recordCreated();
runtime.recordCreated({ state: "ready", liveIdentityFingerprint: SANDBOX_FINGERPRINT });

runtime.complete();

Expand All @@ -233,7 +236,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => {
first.confirmDeleted();
first.advance("creating");
mocks.captureOpenshell.mockReturnValue(replacementProbe());
first.recordCreated();
first.recordCreated({ state: "ready", liveIdentityFingerprint: REPLACEMENT_FINGERPRINT });
first.advance("registry_committing");
vi.spyOn(registry, "getSandbox").mockReturnValue({
name: "alpha",
Expand All @@ -257,7 +260,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => {
first.confirmDeleted();
first.advance("creating");
mocks.captureOpenshell.mockReturnValue(replacementProbe());
first.recordCreated();
first.recordCreated({ state: "ready", liveIdentityFingerprint: REPLACEMENT_FINGERPRINT });
first.advance("completed");
vi.spyOn(registry, "getSandbox").mockReturnValue({
name: "alpha",
Expand Down
21 changes: 20 additions & 1 deletion src/lib/onboard/sandbox-gpu-create-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => {
expect(mocks.enforceDockerGpuPatchPreserveNetwork).not.toHaveBeenCalled();
});

it("configures the portable lifecycle after sandbox creation succeeds (#8441)", async () => {
it("uses the provided lifecycle generation for portable setup and registration (#8942)", async () => {
const input = createInput();
input.lifecycleGeneration = "current-generation";
const deps = createDeps();
Expand All @@ -730,6 +730,25 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => {
);
});

it("preserves the provided lifecycle generation when portable setup is unavailable (#8942)", async () => {
const input = createInput();
input.lifecycleGeneration = "fresh-generation";
const deps = createDeps();
deps.installPortableDemoLifecycle = vi.fn(() => null);

const result = await runSandboxGpuCreateFlow(input, deps);

expect(result.lifecycleRegistrationFields).toEqual({
lifecycleGeneration: "fresh-generation",
});
expect(deps.installPortableDemoLifecycle).toHaveBeenCalledWith(
input.sandboxName,
input.sandboxStartupCommand,
process.env,
{ registryGeneration: "fresh-generation" },
);
});

it("keeps a created sandbox when portable lifecycle setup fails (#8441)", async () => {
const deps = createDeps();
deps.installPortableDemoLifecycle = vi.fn(() => {
Expand Down
Loading
Loading