mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(update): preserve upstream after pinned dev updates (#121328)
This commit is contained in:
committed by
GitHub
parent
2f97e8c9eb
commit
f4e62523af
@@ -14,6 +14,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
|
||||
import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js";
|
||||
import { isBetaTag } from "../infra/update-channels.js";
|
||||
import { applyDevUpdateTargetEnv } from "../infra/update-dev-target.js";
|
||||
import {
|
||||
createDeferredConfiguredPluginRepairDoctorResult,
|
||||
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
|
||||
@@ -7449,6 +7450,87 @@ describe("update-cli", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "ref-only as detached",
|
||||
env: { OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-sha" },
|
||||
expected: { mode: "detached", ref: "frozen-sha" },
|
||||
},
|
||||
{
|
||||
name: "versioned tracked target",
|
||||
env: applyDevUpdateTargetEnv(
|
||||
{},
|
||||
{ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
|
||||
),
|
||||
expected: { mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
|
||||
},
|
||||
])("maps the internal dev target environment $name", async ({ env, expected }) => {
|
||||
await withEnvAsync(env, async () => {
|
||||
await updateCommand({ channel: "dev", yes: true, restart: false });
|
||||
});
|
||||
|
||||
expect(vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ devTarget: expected }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", "openclaw-dev-target:v1:not+base64url"],
|
||||
["unknown version", "openclaw-dev-target:v2:hostile-ref"],
|
||||
["unknown namespace", "other-dev-target:v1:hostile-ref"],
|
||||
])("rejects a %s tracked dev target before update side effects", async (_name, value) => {
|
||||
await withEnvAsync({ OPENCLAW_UPDATE_DEV_TARGET_REF: value }, async () => {
|
||||
await updateCommand({ channel: "dev", yes: true, restart: false });
|
||||
});
|
||||
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||
"Invalid internal OPENCLAW_UPDATE_DEV_TARGET_REF contract; expected a plain Git ref or a supported tracked-target encoding.",
|
||||
);
|
||||
expect(defaultRuntime.error).toHaveBeenCalledTimes(1);
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expectNoSideEffects(
|
||||
cleanupStaleManagedServiceUpdateHandoffs,
|
||||
runGatewayUpdate,
|
||||
launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed inferred dev target before running the update", async () => {
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_UPDATE_DEV_TARGET_REF: "openclaw-dev-target:v1:not+base64url" },
|
||||
async () => {
|
||||
await updateCommand({ yes: true, restart: false });
|
||||
},
|
||||
);
|
||||
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||
"Invalid internal OPENCLAW_UPDATE_DEV_TARGET_REF contract; expected a plain Git ref or a supported tracked-target encoding.",
|
||||
);
|
||||
expect(defaultRuntime.error).toHaveBeenCalledTimes(1);
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a malformed dev target for a stable package update", async () => {
|
||||
mockPackageInstallStatus(createCaseDir("openclaw-stable-update"));
|
||||
mockCurrentProcessFreshDoctor();
|
||||
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_UPDATE_DEV_TARGET_REF: "openclaw-dev-target:v1:not+base64url" },
|
||||
async () => {
|
||||
await updateCommand({ channel: "stable", yes: true, restart: false });
|
||||
},
|
||||
);
|
||||
|
||||
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("OPENCLAW_UPDATE_DEV_TARGET_REF"),
|
||||
);
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(packageInstallCommandCall()).toBeDefined();
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses ~/openclaw as the default dev checkout directory", async () => {
|
||||
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
|
||||
import type { ResolvedGlobalInstallTarget } from "../../infra/update-global.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -53,7 +54,7 @@ export async function executeMutableUpdate(params: {
|
||||
showProgress: boolean;
|
||||
opts: UpdateCommandOptions;
|
||||
shouldRestart: boolean;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
packageInstallSpec: string | null;
|
||||
packageInstallEnv?: NodeJS.ProcessEnv;
|
||||
packageInstallTarget?: ResolvedGlobalInstallTarget;
|
||||
@@ -223,7 +224,7 @@ export async function executeMutableUpdate(params: {
|
||||
showProgress: params.showProgress,
|
||||
opts: params.opts,
|
||||
stop: params.stop,
|
||||
devTargetRef: params.devTargetRef,
|
||||
devTarget: params.devTarget,
|
||||
beforeGitMutation:
|
||||
params.updateInstallKind === "git"
|
||||
? createBeforeGitMutation({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UpdateChannel } from "../../infra/update-channels.js";
|
||||
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
|
||||
import {
|
||||
createGlobalInstallEnv,
|
||||
globalInstallArgs,
|
||||
@@ -127,7 +128,7 @@ export async function runGitUpdate(params: {
|
||||
showProgress: boolean;
|
||||
opts: UpdateCommandOptions;
|
||||
stop: () => void;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
beforeGitMutation?: BeforeGitMutation;
|
||||
allowGatewayServiceRepair: boolean;
|
||||
allowGatewayActivation: boolean;
|
||||
@@ -167,7 +168,7 @@ export async function runGitUpdate(params: {
|
||||
progress: params.progress,
|
||||
channel: params.channel,
|
||||
tag: params.tag,
|
||||
devTargetRef: params.devTargetRef,
|
||||
devTarget: params.devTarget,
|
||||
deferConfiguredPluginInstallRepair: true,
|
||||
allowGatewayServiceRepair: params.allowGatewayServiceRepair,
|
||||
allowGatewayActivation: params.allowGatewayActivation,
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
resolveNpmChannelTag,
|
||||
} from "../../infra/update-check.js";
|
||||
import { readControlPlaneUpdateSentinelMeta } from "../../infra/update-control-plane-sentinel.js";
|
||||
import {
|
||||
parseDevUpdateTargetEnv,
|
||||
type DevUpdateTarget,
|
||||
UPDATE_DEV_TARGET_REF_ENV,
|
||||
} from "../../infra/update-dev-target.js";
|
||||
import {
|
||||
canResolveRegistryVersionForPackageTarget,
|
||||
createGlobalInstallEnv,
|
||||
@@ -85,6 +90,18 @@ export { updateFinalizeCommand } from "./update-command-post-core.js";
|
||||
const CLI_NAME = resolveCliName();
|
||||
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
function readDevUpdateTargetOrExit(): { ok: true; target?: DevUpdateTarget } | { ok: false } {
|
||||
const parsed = parseDevUpdateTargetEnv(process.env);
|
||||
if (parsed.status === "invalid") {
|
||||
defaultRuntime.error(
|
||||
`Invalid internal ${UPDATE_DEV_TARGET_REF_ENV} contract; expected a plain Git ref or a supported tracked-target encoding.`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return { ok: false };
|
||||
}
|
||||
return parsed.status === "valid" ? { ok: true, target: parsed.target } : { ok: true };
|
||||
}
|
||||
|
||||
async function withUpdateInProgressEnv<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
|
||||
@@ -134,6 +151,14 @@ async function updateCommandInternal(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
let devTarget: DevUpdateTarget | undefined;
|
||||
if (requestedChannel === "dev") {
|
||||
const resolvedDevTarget = readDevUpdateTargetOrExit();
|
||||
if (!resolvedDevTarget.ok) {
|
||||
return;
|
||||
}
|
||||
devTarget = resolvedDevTarget.target;
|
||||
}
|
||||
|
||||
if (!postCoreUpdateResume && opts.dryRun !== true && isGatewayExternallySupervised()) {
|
||||
defaultRuntime.error(formatExternalSupervisorUpdateRequired());
|
||||
@@ -246,8 +271,13 @@ async function updateCommandInternal(
|
||||
currentVersion: VERSION,
|
||||
installKind: updateInstallKind,
|
||||
}).channel);
|
||||
const devTargetRef =
|
||||
channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined;
|
||||
if (channel === "dev" && requestedChannel !== "dev") {
|
||||
const resolvedDevTarget = readDevUpdateTargetOrExit();
|
||||
if (!resolvedDevTarget.ok) {
|
||||
return;
|
||||
}
|
||||
devTarget = resolvedDevTarget.target;
|
||||
}
|
||||
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
if (channel === "extended-stable" && explicitTag) {
|
||||
@@ -579,7 +609,7 @@ async function updateCommandInternal(
|
||||
showProgress,
|
||||
opts,
|
||||
shouldRestart,
|
||||
devTargetRef,
|
||||
devTarget,
|
||||
packageInstallSpec,
|
||||
packageInstallEnv,
|
||||
packageInstallTarget,
|
||||
|
||||
@@ -38,7 +38,9 @@ type UpdateInstallSurface = Awaited<
|
||||
>;
|
||||
const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise<UpdateInstallSurface>>();
|
||||
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>();
|
||||
const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({
|
||||
const startManagedServiceUpdateHandoffMock = vi.fn<
|
||||
typeof import("../../infra/update-managed-service-handoff.js").startManagedServiceUpdateHandoff
|
||||
>(async () => ({
|
||||
status: "started" as const,
|
||||
pid: 12345,
|
||||
command: "openclaw update --yes --timeout 1800",
|
||||
@@ -323,7 +325,11 @@ describe("update.run campaign ownership", () => {
|
||||
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "dev",
|
||||
devTargetRef: "frozen-upstream-sha",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-upstream-sha",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -336,9 +342,11 @@ describe("update.run campaign ownership", () => {
|
||||
|
||||
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha",
|
||||
}),
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-upstream-sha",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -350,7 +358,7 @@ describe("update.run campaign ownership", () => {
|
||||
await invokeUpdateRun();
|
||||
|
||||
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ devTargetRef: expect.anything() }),
|
||||
expect.not.objectContaining({ devTarget: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -385,7 +393,7 @@ describe("update.run campaign ownership", () => {
|
||||
expect(getCampaignStateMock).not.toHaveBeenCalled();
|
||||
expect(clearCampaignMock).not.toHaveBeenCalled();
|
||||
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ devTargetRef: expect.anything() }),
|
||||
expect.not.objectContaining({ devTarget: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "../../infra/update-channels.js";
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js";
|
||||
import { devUpdateTargetFromGitCampaign } from "../../infra/update-dev-target.js";
|
||||
import { resolveUpdateInstallRoot } from "../../infra/update-install-root.js";
|
||||
import {
|
||||
buildManagedServiceHandoffUnavailableMessage,
|
||||
@@ -254,9 +255,9 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const adoptedCampaign = gatewayUpdateCampaign.adopt();
|
||||
const adoptedCampaignId = adoptedCampaign?.campaignId;
|
||||
const adoptedDevTargetRef =
|
||||
const adoptedDevTarget =
|
||||
adoptedCampaign?.target.kind === "git"
|
||||
? adoptedCampaign.target.upstreamSha.trim() || undefined
|
||||
? devUpdateTargetFromGitCampaign(adoptedCampaign.target)
|
||||
: undefined;
|
||||
const adoptedPackageTargetVersion =
|
||||
adoptedCampaign?.target.kind === "package"
|
||||
@@ -409,14 +410,7 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
restartDrainTimeoutMs: resolveGatewayRestartDeferralTimeoutMs(),
|
||||
...(handoffChannel ? { channel: handoffChannel } : {}),
|
||||
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
|
||||
...(adoptedDevTargetRef
|
||||
? {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_UPDATE_DEV_TARGET_REF: adoptedDevTargetRef,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
|
||||
restartDelayMs: managedRestartDelayMs,
|
||||
meta: sentinelMeta,
|
||||
handoffId,
|
||||
@@ -529,7 +523,7 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
? effectiveChannel
|
||||
: (configChannel ?? undefined),
|
||||
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
|
||||
...(adoptedDevTargetRef ? { devTargetRef: adoptedDevTargetRef } : {}),
|
||||
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
|
||||
allowGatewayServiceRepair: false,
|
||||
allowGatewayActivation: false,
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
hasRestartSentinel,
|
||||
markUpdateRestartSentinelFailure,
|
||||
readRestartSentinel,
|
||||
readUpdateInstallReceipt,
|
||||
readSuccessfulGitUpdateReceipt,
|
||||
summarizeRestartSentinel,
|
||||
trimLogTail,
|
||||
writeRestartSentinel,
|
||||
@@ -557,7 +557,11 @@ describe("restart sentinel", () => {
|
||||
mode: "git",
|
||||
root: installAlias,
|
||||
before: { sha: "aaaaaaaa" },
|
||||
after: { sha: "bbbbbbbb", version: "expected-version" },
|
||||
after: {
|
||||
sha: " bbbbbbbb ",
|
||||
upstreamRef: " origin/main ",
|
||||
version: "expected-version",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -569,15 +573,11 @@ describe("restart sentinel", () => {
|
||||
);
|
||||
await clearRestartSentinel();
|
||||
|
||||
await expect(readUpdateInstallReceipt()).resolves.toMatchObject({
|
||||
kind: "update",
|
||||
status: "ok",
|
||||
ts,
|
||||
stats: {
|
||||
mode: "git",
|
||||
root: await fs.realpath(installRoot),
|
||||
after: { sha: "bbbbbbbb", version: "actual-version" },
|
||||
},
|
||||
await expect(readSuccessfulGitUpdateReceipt()).resolves.toEqual({
|
||||
root: await fs.realpath(installRoot),
|
||||
sha: "bbbbbbbb",
|
||||
upstreamRef: "origin/main",
|
||||
installedAtMs: ts,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -604,7 +604,7 @@ describe("restart sentinel", () => {
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
|
||||
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,7 +634,7 @@ describe("restart sentinel", () => {
|
||||
stats: { reason: "restart-revision-mismatch" },
|
||||
},
|
||||
});
|
||||
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
|
||||
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -670,7 +670,7 @@ describe("restart sentinel", () => {
|
||||
stats: { reason: "restart-root-mismatch" },
|
||||
},
|
||||
});
|
||||
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
|
||||
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,13 @@ export type {
|
||||
RestartSentinelPayload,
|
||||
} from "./restart-sentinel-store.js";
|
||||
|
||||
export type SuccessfulGitUpdateReceipt = {
|
||||
root: string;
|
||||
sha: string;
|
||||
upstreamRef?: string;
|
||||
installedAtMs: number;
|
||||
};
|
||||
|
||||
const sentinelLog = createSubsystemLogger("restart-sentinel");
|
||||
|
||||
export function formatDoctorNonInteractiveHint(
|
||||
@@ -245,7 +252,7 @@ export async function readRestartSentinel(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readUpdateInstallReceipt(
|
||||
async function readUpdateInstallReceiptPayload(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<RestartSentinelPayload | null> {
|
||||
try {
|
||||
@@ -257,6 +264,40 @@ export async function readUpdateInstallReceipt(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSuccessfulGitUpdateReceipt(
|
||||
payload: RestartSentinelPayload | null,
|
||||
): SuccessfulGitUpdateReceipt | null {
|
||||
if (
|
||||
payload?.kind !== "update" ||
|
||||
payload.status !== "ok" ||
|
||||
payload.stats?.mode !== "git" ||
|
||||
!isPlainRecord(payload.stats.after)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const root = typeof payload.stats.root === "string" ? payload.stats.root.trim() : "";
|
||||
const sha = typeof payload.stats.after.sha === "string" ? payload.stats.after.sha.trim() : "";
|
||||
if (!root || !sha) {
|
||||
return null;
|
||||
}
|
||||
const upstreamRef =
|
||||
typeof payload.stats.after.upstreamRef === "string"
|
||||
? payload.stats.after.upstreamRef.trim()
|
||||
: "";
|
||||
return {
|
||||
root,
|
||||
sha,
|
||||
...(upstreamRef ? { upstreamRef } : {}),
|
||||
installedAtMs: payload.ts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSuccessfulGitUpdateReceipt(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<SuccessfulGitUpdateReceipt | null> {
|
||||
return normalizeSuccessfulGitUpdateReceipt(await readUpdateInstallReceiptPayload(env));
|
||||
}
|
||||
|
||||
export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): Promise<boolean> {
|
||||
try {
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
|
||||
@@ -627,6 +627,73 @@ describe("formatGitInstallLabel", () => {
|
||||
});
|
||||
|
||||
describe("checkUpdateStatus", () => {
|
||||
it("uses a matching receipt upstream only for the detached installed revision", async () => {
|
||||
await withTempDir({ prefix: "openclaw-update-check-receipt-fallback-" }, async (base) => {
|
||||
const sourceRoot = path.join(base, "source");
|
||||
const localRoot = path.join(base, "local");
|
||||
await initGitRepo(sourceRoot);
|
||||
await fs.writeFile(
|
||||
path.join(sourceRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
|
||||
);
|
||||
await runGit(sourceRoot, "add", "package.json");
|
||||
await commitGit(sourceRoot, "base");
|
||||
const baseSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await commitGit(sourceRoot, "target");
|
||||
const targetSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await runGit(base, "clone", "--quiet", sourceRoot, localRoot);
|
||||
await runGit(localRoot, "checkout", "--detach", targetSha);
|
||||
const fallback = { currentSha: targetSha, upstreamRef: "origin/main" };
|
||||
const readStatus = (params: { fetch?: boolean; fallback?: typeof fallback } = {}) =>
|
||||
checkUpdateStatus({
|
||||
root: localRoot,
|
||||
includeRegistry: false,
|
||||
fetchGit: params.fetch ?? false,
|
||||
timeoutMs: 5000,
|
||||
...(params.fallback ? { gitUpstreamFallback: params.fallback } : {}),
|
||||
});
|
||||
|
||||
const current = await readStatus({ fetch: true, fallback });
|
||||
expect(current.git).toMatchObject({
|
||||
branch: "HEAD",
|
||||
sha: targetSha,
|
||||
upstream: "origin/main",
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: targetSha,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
await commitGit(sourceRoot, "newer");
|
||||
const newerSha = await runGit(sourceRoot, "rev-parse", "HEAD");
|
||||
const behind = await readStatus({ fetch: true, fallback });
|
||||
expect(behind.git).toMatchObject({
|
||||
upstreamSource: "receipt",
|
||||
upstreamSha: newerSha,
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
});
|
||||
|
||||
for (const fallbackOverride of [undefined, { ...fallback, currentSha: baseSha }]) {
|
||||
const unmanaged = await readStatus({ fallback: fallbackOverride });
|
||||
expect(unmanaged.git).toMatchObject({ branch: "HEAD", upstream: null });
|
||||
expect(unmanaged.git).not.toHaveProperty("upstreamSource");
|
||||
}
|
||||
|
||||
await runGit(localRoot, "checkout", "-b", "receipt-collision", targetSha);
|
||||
const namedBranch = await readStatus({ fallback });
|
||||
expect(namedBranch.git).toMatchObject({
|
||||
branch: "receipt-collision",
|
||||
sha: targetSha,
|
||||
upstream: null,
|
||||
upstreamSha: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
expect(namedBranch.git).not.toHaveProperty("upstreamSource");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat stale remote refs as current when fetch fails", async () => {
|
||||
await withTempDir({ prefix: "openclaw-update-check-fetch-failure-" }, async (base) => {
|
||||
const remoteRoot = path.join(base, "remote");
|
||||
|
||||
@@ -24,6 +24,7 @@ type GitUpdateStatus = {
|
||||
tag: string | null;
|
||||
branch: string | null;
|
||||
upstream: string | null;
|
||||
upstreamSource?: "tracking" | "receipt";
|
||||
upstreamSha?: string | null;
|
||||
commitAtMs?: number | null;
|
||||
dirty: boolean | null;
|
||||
@@ -228,6 +229,7 @@ async function checkGitUpdateStatus(params: {
|
||||
root: string;
|
||||
timeoutMs?: number;
|
||||
fetch?: boolean;
|
||||
upstreamFallback?: { currentSha: string; upstreamRef: string };
|
||||
}): Promise<GitUpdateStatus> {
|
||||
const timeoutMs = params.timeoutMs ?? 6000;
|
||||
const root = path.resolve(params.root);
|
||||
@@ -281,7 +283,21 @@ async function checkGitUpdateStatus(params: {
|
||||
|
||||
const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null;
|
||||
|
||||
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
|
||||
const trackingUpstream =
|
||||
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() || null : null;
|
||||
const receiptUpstream =
|
||||
!trackingUpstream &&
|
||||
branch === "HEAD" &&
|
||||
sha &&
|
||||
params.upstreamFallback?.currentSha.trim().toLowerCase() === sha.toLowerCase()
|
||||
? params.upstreamFallback.upstreamRef.trim() || null
|
||||
: null;
|
||||
const upstream = trackingUpstream ?? receiptUpstream;
|
||||
const upstreamSource = trackingUpstream
|
||||
? ("tracking" as const)
|
||||
: receiptUpstream
|
||||
? ("receipt" as const)
|
||||
: undefined;
|
||||
|
||||
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
|
||||
@@ -293,13 +309,14 @@ async function checkGitUpdateStatus(params: {
|
||||
|
||||
const canCompareUpstream = !params.fetch || fetchOk === true;
|
||||
|
||||
// Freeze the post-fetch upstream for both graph queries. Resolve via @{upstream} rather than
|
||||
// its display name so dashed remotes stay operands on older Git versions. Three-dot rev-list
|
||||
// still counts disconnected or truncated histories, so require a visible common ancestor.
|
||||
// Freeze the post-fetch upstream for both graph queries. Active tracking wins;
|
||||
// a matching successful update receipt keeps intentional detached installs comparable.
|
||||
const upstreamRevision =
|
||||
upstreamSource === "tracking" ? "@{upstream}^{commit}" : `${upstream}^{commit}`;
|
||||
const upstreamCommitRes =
|
||||
canCompareUpstream && upstream && sha
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--verify", "@{upstream}^{commit}"],
|
||||
["git", "-C", root, "rev-parse", "--verify", upstreamRevision],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
@@ -339,6 +356,7 @@ async function checkGitUpdateStatus(params: {
|
||||
tag,
|
||||
branch,
|
||||
upstream,
|
||||
...(upstreamSource ? { upstreamSource } : {}),
|
||||
upstreamSha: upstreamCommit,
|
||||
commitAtMs,
|
||||
dirty,
|
||||
@@ -590,6 +608,7 @@ export async function checkUpdateStatus(params: {
|
||||
root: string | null;
|
||||
timeoutMs?: number;
|
||||
fetchGit?: boolean;
|
||||
gitUpstreamFallback?: { currentSha: string; upstreamRef: string };
|
||||
includeRegistry?: boolean;
|
||||
registryChannel?: UpdateChannel;
|
||||
resolveRegistryChannel?: (
|
||||
@@ -639,6 +658,7 @@ export async function checkUpdateStatus(params: {
|
||||
root,
|
||||
timeoutMs,
|
||||
fetch: Boolean(params.fetchGit),
|
||||
upstreamFallback: params.gitUpstreamFallback,
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
checkDepsStatus({ root, manager: packageManager }),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyDevUpdateTargetEnv,
|
||||
devUpdateTargetFromGitCampaign,
|
||||
parseDevUpdateTargetEnv,
|
||||
resolveDevUpdateTargetRevision,
|
||||
} from "./update-dev-target.js";
|
||||
|
||||
const TRACKED_VALUE =
|
||||
"openclaw-dev-target:v1:eyJ1cHN0cmVhbVJlZiI6Im9yaWdpbi9tYWluIiwidXBzdHJlYW1TaGEiOiJmcm96ZW4tc2hhIn0";
|
||||
|
||||
describe("dev update target environment", () => {
|
||||
it("preserves the legacy plain detached-ref contract", () => {
|
||||
expect(parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: " refs/tags/dev " })).toEqual({
|
||||
status: "valid",
|
||||
target: { mode: "detached", ref: "refs/tags/dev" },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes an absent target from an invalid one", () => {
|
||||
expect(parseDevUpdateTargetEnv({})).toEqual({ status: "absent" });
|
||||
expect(
|
||||
parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: "refs/heads/my branch" }),
|
||||
).toEqual({ status: "invalid" });
|
||||
});
|
||||
|
||||
it("serializes tracked targets deterministically through the existing env field", () => {
|
||||
const env = applyDevUpdateTargetEnv(
|
||||
{ KEEP: "value" },
|
||||
{ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
|
||||
);
|
||||
|
||||
expect(env).toEqual({ KEEP: "value", OPENCLAW_UPDATE_DEV_TARGET_REF: TRACKED_VALUE });
|
||||
expect(parseDevUpdateTargetEnv(env)).toEqual({
|
||||
status: "valid",
|
||||
target: { mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
|
||||
});
|
||||
});
|
||||
|
||||
it("projects campaign targets and resolves both target modes", () => {
|
||||
const tracked = devUpdateTargetFromGitCampaign({
|
||||
kind: "git",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-sha",
|
||||
commitsBehind: 2,
|
||||
});
|
||||
|
||||
expect(tracked).toEqual({
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-sha",
|
||||
});
|
||||
expect(resolveDevUpdateTargetRevision(tracked)).toBe("frozen-sha");
|
||||
expect(resolveDevUpdateTargetRevision({ mode: "detached", ref: "refs/tags/dev" })).toBe(
|
||||
"refs/tags/dev",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"other-dev-target:v1:payload",
|
||||
"openclaw-dev-target:v2:payload",
|
||||
"openclaw-dev-target:v1:",
|
||||
"openclaw-dev-target:v1:not+base64url",
|
||||
`openclaw-dev-target:v1:${"a".repeat(4097)}`,
|
||||
`openclaw-dev-target:v1:${Buffer.from("not-json").toString("base64url")}`,
|
||||
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify(["ref", "origin/main"])).toString("base64url")}`,
|
||||
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "ref" })).toString("base64url")}`,
|
||||
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ upstreamRef: " upstream ", upstreamSha: "ref" })).toString("base64url")}`,
|
||||
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ upstreamRef: "origin/main", upstreamSha: "ref\0" })).toString("base64url")}`,
|
||||
])("fails closed for malformed or unsupported tracked value %s", (value) => {
|
||||
expect(parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: value })).toEqual({
|
||||
status: "invalid",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { safeParseJson, stableStringify } from "@openclaw/normalization-core";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type { UpdateScheduleState } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
export const UPDATE_DEV_TARGET_REF_ENV = "OPENCLAW_UPDATE_DEV_TARGET_REF";
|
||||
const TRACKED_DEV_TARGET_PREFIX = "openclaw-dev-target:v1:";
|
||||
const MAX_TRACKED_DEV_TARGET_PAYLOAD_LENGTH = 4096;
|
||||
|
||||
export type DevUpdateTarget =
|
||||
| { mode: "detached"; ref: string }
|
||||
| { mode: "tracked"; upstreamRef: string; upstreamSha: string };
|
||||
|
||||
export type TrackedDevUpdateTarget = Extract<DevUpdateTarget, { mode: "tracked" }>;
|
||||
type GitUpdateCampaignTarget = Extract<NonNullable<UpdateScheduleState["target"]>, { kind: "git" }>;
|
||||
|
||||
type DevUpdateTargetEnvParseResult =
|
||||
| { status: "absent" }
|
||||
| { status: "valid"; target: DevUpdateTarget }
|
||||
| { status: "invalid" };
|
||||
|
||||
function isValidTargetPart(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
!/\s/u.test(value) &&
|
||||
Array.from(value).every((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 0x20 && code !== 0x7f;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function parseTrackedTarget(payload: string): TrackedDevUpdateTarget | undefined {
|
||||
if (
|
||||
payload.length === 0 ||
|
||||
payload.length > MAX_TRACKED_DEV_TARGET_PAYLOAD_LENGTH ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(payload)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const json = Buffer.from(payload, "base64url").toString("utf8");
|
||||
if (Buffer.from(json, "utf8").toString("base64url") !== payload) {
|
||||
return undefined;
|
||||
}
|
||||
const decoded = safeParseJson(json);
|
||||
if (
|
||||
!isRecord(decoded) ||
|
||||
Object.keys(decoded).length !== 2 ||
|
||||
!("upstreamRef" in decoded) ||
|
||||
!("upstreamSha" in decoded)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const { upstreamRef, upstreamSha } = decoded;
|
||||
if (!isValidTargetPart(upstreamRef) || !isValidTargetPart(upstreamSha)) {
|
||||
return undefined;
|
||||
}
|
||||
return { mode: "tracked", upstreamRef, upstreamSha };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDevUpdateTargetRevision(target: DevUpdateTarget): string {
|
||||
return target.mode === "tracked" ? target.upstreamSha : target.ref;
|
||||
}
|
||||
|
||||
export function devUpdateTargetFromGitCampaign(
|
||||
target: GitUpdateCampaignTarget,
|
||||
): TrackedDevUpdateTarget {
|
||||
return {
|
||||
mode: "tracked",
|
||||
upstreamRef: target.upstreamRef,
|
||||
upstreamSha: target.upstreamSha,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDevUpdateTargetEnv(env: NodeJS.ProcessEnv): DevUpdateTargetEnvParseResult {
|
||||
const value = env[UPDATE_DEV_TARGET_REF_ENV]?.trim();
|
||||
if (!value) {
|
||||
return { status: "absent" };
|
||||
}
|
||||
if (value.startsWith(TRACKED_DEV_TARGET_PREFIX)) {
|
||||
const target = parseTrackedTarget(value.slice(TRACKED_DEV_TARGET_PREFIX.length));
|
||||
return target ? { status: "valid", target } : { status: "invalid" };
|
||||
}
|
||||
if (value.includes(":")) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
return isValidTargetPart(value)
|
||||
? { status: "valid", target: { mode: "detached", ref: value } }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
|
||||
export function applyDevUpdateTargetEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
target: DevUpdateTarget,
|
||||
): NodeJS.ProcessEnv {
|
||||
// Preserve the one-env handoff and shipped plain-ref contract; the namespaced
|
||||
// tracked encoding cannot be silently reinterpreted as a Git ref.
|
||||
const value =
|
||||
target.mode === "tracked"
|
||||
? `${TRACKED_DEV_TARGET_PREFIX}${Buffer.from(
|
||||
stableStringify({
|
||||
upstreamRef: target.upstreamRef,
|
||||
upstreamSha: target.upstreamSha,
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url")}`
|
||||
: target.ref;
|
||||
return { ...env, [UPDATE_DEV_TARGET_REF_ENV]: value };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseDevUpdateTargetEnv, type DevUpdateTarget } from "./update-dev-target.js";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const tempDirs = new Set<string>();
|
||||
@@ -44,7 +45,13 @@ afterEach(async () => {
|
||||
async function startHandoffAndReadCommand(params: {
|
||||
channel: "beta" | "extended-stable";
|
||||
tag?: string;
|
||||
}): Promise<{ command: string; commandArgv: string[] | undefined }> {
|
||||
devTarget?: DevUpdateTarget;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{
|
||||
command: string;
|
||||
commandArgv: string[] | undefined;
|
||||
spawnEnv: NodeJS.ProcessEnv | undefined;
|
||||
}> {
|
||||
const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js");
|
||||
const result = await startManagedServiceUpdateHandoff({
|
||||
root: "/tmp/openclaw",
|
||||
@@ -55,8 +62,12 @@ async function startHandoffAndReadCommand(params: {
|
||||
execPath: "/usr/local/bin/node",
|
||||
argv1: "/opt/openclaw/openclaw.mjs",
|
||||
meta: {},
|
||||
...(params.devTarget ? { devTarget: params.devTarget } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
});
|
||||
const spawnCall = spawnMock.mock.calls[0] as unknown as [string, string[]] | undefined;
|
||||
const spawnCall = spawnMock.mock.calls[0] as unknown as
|
||||
| [string, string[], { env?: NodeJS.ProcessEnv }]
|
||||
| undefined;
|
||||
const paramsPath = spawnCall?.[1]?.[1];
|
||||
if (!paramsPath) {
|
||||
throw new Error("expected managed-service handoff params path");
|
||||
@@ -72,7 +83,11 @@ async function startHandoffAndReadCommand(params: {
|
||||
expect(metaFile.meta?.root).toBe(
|
||||
await fs.realpath("/tmp/openclaw").catch(() => path.resolve("/tmp/openclaw")),
|
||||
);
|
||||
return { command: result.command, commandArgv: helperParams.commandArgv };
|
||||
return {
|
||||
command: result.command,
|
||||
commandArgv: helperParams.commandArgv,
|
||||
spawnEnv: spawnCall?.[2]?.env,
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed service update handoff command", () => {
|
||||
@@ -111,4 +126,29 @@ describe("managed service update handoff command", () => {
|
||||
expect(result.command).toContain("--tag 2.0.0-beta.1");
|
||||
expect(result.command).toContain("--channel beta");
|
||||
});
|
||||
|
||||
it("merges a tracked target into the child environment without replacing caller fields", async () => {
|
||||
const result = await startHandoffAndReadCommand({
|
||||
channel: "beta",
|
||||
env: {
|
||||
KEEP: "value",
|
||||
OPENCLAW_UPDATE_DEV_TARGET_REF: "stale-ref",
|
||||
},
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-sha",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.spawnEnv?.KEEP).toBe("value");
|
||||
expect(parseDevUpdateTargetEnv(result.spawnEnv ?? {})).toEqual({
|
||||
status: "valid",
|
||||
target: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-sha",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CONTROL_PLANE_UPDATE_SENTINEL_META_ENV,
|
||||
type ControlPlaneUpdateSentinelMetaFile,
|
||||
} from "./update-control-plane-sentinel.js";
|
||||
import { applyDevUpdateTargetEnv, type DevUpdateTarget } from "./update-dev-target.js";
|
||||
import { resolveUpdateInstallRoot } from "./update-install-root.js";
|
||||
import { MANAGED_SERVICE_UPDATE_HANDOFF_TEMP_PREFIX } from "./update-managed-service-handoff-cleanup.js";
|
||||
import type { UpdateRestartSentinelMeta } from "./update-restart-sentinel-payload.js";
|
||||
@@ -679,6 +680,7 @@ type ManagedServiceUpdateHandoffParams = {
|
||||
handoffId?: string;
|
||||
supervisor?: RespawnSupervisor | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
devTarget?: DevUpdateTarget;
|
||||
execPath?: string;
|
||||
argv1?: string;
|
||||
parentPid?: number;
|
||||
@@ -1026,11 +1028,12 @@ async function spawnManagedServiceUpdateHandoff(
|
||||
await fs.writeFile(paramsPath, `${JSON.stringify(helperParams, null, 2)}\n`, { mode: 0o600 });
|
||||
await fs.writeFile(metaPath, `${JSON.stringify(metaFile, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
const env = {
|
||||
const childEnv = {
|
||||
...stripSupervisorHintEnv(params.env ?? process.env),
|
||||
[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
|
||||
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
|
||||
};
|
||||
const env = params.devTarget ? applyDevUpdateTargetEnv(childEnv, params.devTarget) : childEnv;
|
||||
const spawnTarget = await resolveHandoffSpawn({
|
||||
supervisor: params.supervisor,
|
||||
env,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { trimLogTail } from "./restart-sentinel.js";
|
||||
import { DEV_BRANCH } from "./update-channels.js";
|
||||
import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js";
|
||||
import {
|
||||
managerInstallArgs,
|
||||
managerInstallIgnoreScriptsArgs,
|
||||
@@ -413,7 +414,7 @@ async function testPreflightCandidates(params: {
|
||||
|
||||
export async function runGitDevPreflight(params: {
|
||||
gitRoot: string;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
needsCheckoutMain: boolean;
|
||||
runCommand: CommandRunner;
|
||||
timeoutMs: number;
|
||||
@@ -421,7 +422,9 @@ export async function runGitDevPreflight(params: {
|
||||
steps: UpdateStepResult[];
|
||||
step: StepFactory;
|
||||
}): Promise<GitDevPreflightResult> {
|
||||
const devTargetRef = normalizeDevTargetRef(params.devTargetRef);
|
||||
const devTargetRef = params.devTarget
|
||||
? normalizeDevTargetRef(resolveDevUpdateTargetRevision(params.devTarget))
|
||||
: null;
|
||||
let preflightBaseSha: string;
|
||||
let candidates: string[];
|
||||
let selectedDevUpstream: string | null = null;
|
||||
@@ -433,6 +436,26 @@ export async function runGitDevPreflight(params: {
|
||||
}
|
||||
preflightBaseSha = targetSha;
|
||||
candidates = [targetSha];
|
||||
if (params.devTarget?.mode === "tracked") {
|
||||
const ancestryStep = await runStep(
|
||||
params.step(
|
||||
"tracked target ancestry",
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
params.gitRoot,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
targetSha,
|
||||
`${params.devTarget.upstreamRef}^{commit}`,
|
||||
],
|
||||
params.gitRoot,
|
||||
),
|
||||
);
|
||||
if (ancestryStep.exitCode !== 0) {
|
||||
return { status: "error", reason: "tracked-upstream-invalid" };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const upstream = await resolveUpstreamCandidates(params);
|
||||
if (upstream.status !== "ok") {
|
||||
|
||||
@@ -68,8 +68,9 @@ export async function runGitUpdate(params: {
|
||||
const beforeSha = beforeShaResult.stdout.trim() || null;
|
||||
const beforeVersion = await readPackageVersion(gitRoot);
|
||||
const branch = await readBranchName(runCommand, gitRoot, timeoutMs);
|
||||
const hasDevTargetRef = channel === "dev" && Boolean(opts.devTargetRef?.trim());
|
||||
const needsCheckoutMain = channel === "dev" && !hasDevTargetRef && branch !== DEV_BRANCH;
|
||||
const devTarget = channel === "dev" ? opts.devTarget : undefined;
|
||||
const hasDevTarget = devTarget !== undefined;
|
||||
const needsCheckoutMain = channel === "dev" && !hasDevTarget && branch !== DEV_BRANCH;
|
||||
const totalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
|
||||
const steps: UpdateStepResult[] = [];
|
||||
let stepIndex = 0;
|
||||
@@ -95,6 +96,7 @@ export async function runGitUpdate(params: {
|
||||
let allowGatewayActivation = opts.allowGatewayActivation === true;
|
||||
let mutationPrepared = false;
|
||||
let createdDevBranchDuringUpdate = false;
|
||||
let devPreflight: Awaited<ReturnType<typeof runGitDevPreflight>> | undefined;
|
||||
let liveBuildStarted = false;
|
||||
let recovery: UpdateRunResult["recovery"];
|
||||
const prepareMutation = async (revision: string) => {
|
||||
@@ -283,9 +285,9 @@ export async function runGitUpdate(params: {
|
||||
if (fetchFailure) {
|
||||
return fetchFailure;
|
||||
}
|
||||
const preflight = await runGitDevPreflight({
|
||||
devPreflight = await runGitDevPreflight({
|
||||
gitRoot,
|
||||
devTargetRef: opts.devTargetRef,
|
||||
devTarget,
|
||||
needsCheckoutMain,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
@@ -293,11 +295,12 @@ export async function runGitUpdate(params: {
|
||||
steps,
|
||||
step,
|
||||
});
|
||||
const preflight = devPreflight;
|
||||
if (preflight.status !== "ok") {
|
||||
return buildError(preflight.reason, preflight.status);
|
||||
}
|
||||
await prepareMutation(preflight.selectedSha);
|
||||
if (hasDevTargetRef) {
|
||||
if (hasDevTarget) {
|
||||
const failure = await runRequiredStep(
|
||||
`git checkout ${preflight.selectedSha}`,
|
||||
["git", "-C", gitRoot, "checkout", "--detach", preflight.selectedSha],
|
||||
@@ -553,6 +556,13 @@ export async function runGitUpdate(params: {
|
||||
const afterShaStep = await runStep(
|
||||
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
|
||||
);
|
||||
if (
|
||||
devTarget?.mode === "tracked" &&
|
||||
devPreflight?.status === "ok" &&
|
||||
afterShaStep.stdoutTail?.trim() !== devPreflight.selectedSha
|
||||
) {
|
||||
return await rollbackError("target-sha-mismatch");
|
||||
}
|
||||
return {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: "git",
|
||||
@@ -562,6 +572,9 @@ export async function runGitUpdate(params: {
|
||||
after: {
|
||||
sha: afterShaStep.stdoutTail?.trim() ?? null,
|
||||
version: await readPackageVersion(gitRoot),
|
||||
...(!failedStep && devTarget?.mode === "tracked"
|
||||
? { upstreamRef: devTarget.upstreamRef }
|
||||
: {}),
|
||||
},
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandOptions } from "../process/exec.js";
|
||||
import type { OpenClawSchemaVersions } from "../state/openclaw-schema-versions.js";
|
||||
import type { PackageUpdateStepAdvisory } from "./package-update-steps.js";
|
||||
import type { UpdateChannel } from "./update-channels.js";
|
||||
import type { DevUpdateTarget } from "./update-dev-target.js";
|
||||
import type { GlobalInstallManager } from "./update-global.js";
|
||||
|
||||
export type UpdateStepAdvisory = PackageUpdateStepAdvisory;
|
||||
@@ -26,7 +27,7 @@ export type UpdateRunResult = {
|
||||
root?: string;
|
||||
reason?: string;
|
||||
before?: { sha?: string | null; version?: string | null };
|
||||
after?: { sha?: string | null; version?: string | null };
|
||||
after?: { sha?: string | null; version?: string | null; upstreamRef?: string };
|
||||
steps: UpdateStepResult[];
|
||||
durationMs: number;
|
||||
recovery?:
|
||||
@@ -128,7 +129,7 @@ export type UpdateRunnerOptions = {
|
||||
argv1?: string;
|
||||
tag?: string;
|
||||
channel?: UpdateChannel;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
deferConfiguredPluginInstallRepair?: boolean;
|
||||
allowGatewayServiceRepair?: boolean;
|
||||
allowGatewayActivation?: boolean;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts";
|
||||
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
|
||||
import { pathExists } from "../utils.js";
|
||||
import { resolveStableNodePath } from "./stable-node-path.js";
|
||||
import type { UpdateChannel } from "./update-channels.js";
|
||||
import type { DevUpdateTarget } from "./update-dev-target.js";
|
||||
import {
|
||||
resolveUpdateDoctorExecutionPolicy,
|
||||
resolveUpdateInstallSurface,
|
||||
@@ -516,6 +518,73 @@ describe("runGatewayUpdate", () => {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runRealGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const result = await runCommandWithTimeout(["git", ...args], { cwd, timeoutMs: 5000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function createTrackedGitFixture(detached: boolean) {
|
||||
const sourceRoot = await fixtureRootTracker.make("tracked-source");
|
||||
const localRoot = await fixtureRootTracker.make("tracked-local");
|
||||
await runRealGit(sourceRoot, "init", "--initial-branch=main");
|
||||
await runRealGit(sourceRoot, "config", "user.name", "OpenClaw Test");
|
||||
await runRealGit(sourceRoot, "config", "user.email", "openclaw@example.com");
|
||||
await fs.writeFile(
|
||||
path.join(sourceRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@10.0.0" }),
|
||||
);
|
||||
await fs.writeFile(path.join(sourceRoot, "openclaw.mjs"), "export {};\n");
|
||||
await fs.writeFile(path.join(sourceRoot, "README.md"), "base\n");
|
||||
await runRealGit(sourceRoot, "add", "package.json", "openclaw.mjs", "README.md");
|
||||
await runRealGit(sourceRoot, "commit", "-m", "base");
|
||||
const baseSha = await runRealGit(sourceRoot, "rev-parse", "HEAD");
|
||||
await runRealGit(path.dirname(localRoot), "clone", "--quiet", sourceRoot, localRoot);
|
||||
await runRealGit(localRoot, "config", "user.name", "OpenClaw Test");
|
||||
await runRealGit(localRoot, "config", "user.email", "openclaw@example.com");
|
||||
if (detached) {
|
||||
await runRealGit(localRoot, "checkout", "--detach", baseSha);
|
||||
}
|
||||
await fs.writeFile(path.join(sourceRoot, "README.md"), "target\n");
|
||||
await runRealGit(sourceRoot, "add", "README.md");
|
||||
await runRealGit(sourceRoot, "commit", "-m", "target");
|
||||
const targetSha = await runRealGit(sourceRoot, "rev-parse", "HEAD");
|
||||
return { sourceRoot, localRoot, baseSha, targetSha };
|
||||
}
|
||||
|
||||
function createRealGitUpdateRunner(params: { finalHead?: { root: string; sha: string } } = {}) {
|
||||
let headReads = 0;
|
||||
return async (argv: string[], options: { cwd?: string; timeoutMs?: number }) => {
|
||||
if (argv[0] === "git") {
|
||||
const finalHead = params.finalHead;
|
||||
if (
|
||||
finalHead &&
|
||||
argv[2] === finalHead.root &&
|
||||
argv[3] === "rev-parse" &&
|
||||
argv[4] === "HEAD"
|
||||
) {
|
||||
headReads += 1;
|
||||
if (headReads === 2) {
|
||||
return toCommandResult({ stdout: finalHead.sha });
|
||||
}
|
||||
}
|
||||
return await runCommandWithTimeout(argv, {
|
||||
cwd: options.cwd,
|
||||
timeoutMs: options.timeoutMs ?? 5000,
|
||||
});
|
||||
}
|
||||
if (argv[0] === "pnpm" && (argv[1] === "build" || argv[1] === "ui:build")) {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const uiDir = path.join(cwd, "dist", "control-ui");
|
||||
await fs.mkdir(uiDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uiDir, "index.html"), "ok\n");
|
||||
}
|
||||
return toCommandResult();
|
||||
};
|
||||
}
|
||||
|
||||
async function runWithCommand(
|
||||
runCommand: (
|
||||
argv: string[],
|
||||
@@ -525,7 +594,7 @@ describe("runGatewayUpdate", () => {
|
||||
channel?: UpdateChannel;
|
||||
tag?: string;
|
||||
cwd?: string;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
deferConfiguredPluginInstallRepair?: boolean;
|
||||
allowGatewayServiceRepair?: boolean;
|
||||
allowGatewayActivation?: boolean;
|
||||
@@ -543,7 +612,7 @@ describe("runGatewayUpdate", () => {
|
||||
timeoutMs: 5000,
|
||||
...(options?.channel ? { channel: options.channel } : {}),
|
||||
...(options?.tag ? { tag: options.tag } : {}),
|
||||
...(options?.devTargetRef ? { devTargetRef: options.devTargetRef } : {}),
|
||||
...(options?.devTarget ? { devTarget: options.devTarget } : {}),
|
||||
...(options?.deferConfiguredPluginInstallRepair
|
||||
? { deferConfiguredPluginInstallRepair: true }
|
||||
: {}),
|
||||
@@ -561,7 +630,7 @@ describe("runGatewayUpdate", () => {
|
||||
channel?: UpdateChannel;
|
||||
tag?: string;
|
||||
cwd?: string;
|
||||
devTargetRef?: string;
|
||||
devTarget?: DevUpdateTarget;
|
||||
deferConfiguredPluginInstallRepair?: boolean;
|
||||
beforeGitMutation?: (target: {
|
||||
schemaVersions?: { state: number; agent: number };
|
||||
@@ -723,7 +792,7 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
it.each([
|
||||
{ name: "upstream", options: {} },
|
||||
{ name: "target ref", options: { devTargetRef: "main" } },
|
||||
{ name: "target ref", options: { devTarget: { mode: "detached", ref: "main" } } },
|
||||
] as const)("stops dev update when fetch fails before resolving $name", async ({ options }) => {
|
||||
await setupGitCheckout();
|
||||
const fetchCommand = `git -C ${tempDir} fetch --all --prune --no-tags`;
|
||||
@@ -1092,7 +1161,7 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
const result = await runWithRunner(runner, {
|
||||
channel: "dev",
|
||||
devTargetRef: "refs/tags/v2026.5.19-beta.2",
|
||||
devTarget: { mode: "detached", ref: "refs/tags/v2026.5.19-beta.2" },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
@@ -1118,7 +1187,7 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
const result = await runWithRunner(runner, {
|
||||
channel: "dev",
|
||||
devTargetRef: "refs/tags/v2026.5.19-beta.2",
|
||||
devTarget: { mode: "detached", ref: "refs/tags/v2026.5.19-beta.2" },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
@@ -2194,13 +2263,122 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: targetSha });
|
||||
const result = await runWithCommand(runCommand, {
|
||||
channel: "dev",
|
||||
devTarget: { mode: "detached", ref: targetSha },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(calls).toContain(`git -C ${tempDir} rev-parse ${targetSha}`);
|
||||
expect(calls).toContain(`git -C ${tempDir} checkout --detach ${targetSha}`);
|
||||
expect(calls).not.toContain(`git -C ${tempDir} rev-parse @{upstream}`);
|
||||
expect(calls).not.toContain(`git -C ${tempDir} rebase ${targetSha}`);
|
||||
expect(result.after).not.toHaveProperty("upstreamRef");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "main", detached: false },
|
||||
{ name: "detached HEAD", detached: true },
|
||||
])(
|
||||
"keeps a tracked dev target detached from $name and records its verified upstream",
|
||||
async ({ detached }) => {
|
||||
const { localRoot, targetSha } = await createTrackedGitFixture(detached);
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: localRoot,
|
||||
channel: "dev",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: targetSha,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
runCommand: createRealGitUpdateRunner(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.after).toMatchObject({ sha: targetSha, upstreamRef: "origin/main" });
|
||||
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("HEAD");
|
||||
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(targetSha);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a tracked target that is unrelated to its authoritative upstream", async () => {
|
||||
const { sourceRoot, localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
|
||||
await runRealGit(sourceRoot, "checkout", "-b", "unrelated", baseSha);
|
||||
await fs.writeFile(path.join(sourceRoot, "README.md"), "unrelated\n");
|
||||
await runRealGit(sourceRoot, "add", "README.md");
|
||||
await runRealGit(sourceRoot, "commit", "-m", "unrelated target");
|
||||
const beforeGitMutation = vi.fn<() => Promise<void>>();
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: localRoot,
|
||||
channel: "dev",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/unrelated",
|
||||
upstreamSha: targetSha,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
runCommand: createRealGitUpdateRunner(),
|
||||
beforeGitMutation,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("tracked-upstream-invalid");
|
||||
expect(beforeGitMutation).not.toHaveBeenCalled();
|
||||
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
|
||||
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(baseSha);
|
||||
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe(
|
||||
"origin/main",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a tracked target whose authoritative upstream is missing", async () => {
|
||||
const { localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
|
||||
const beforeGitMutation = vi.fn<() => Promise<void>>();
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: localRoot,
|
||||
channel: "dev",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/missing",
|
||||
upstreamSha: targetSha,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
runCommand: createRealGitUpdateRunner(),
|
||||
beforeGitMutation,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("tracked-upstream-invalid");
|
||||
expect(beforeGitMutation).not.toHaveBeenCalled();
|
||||
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
|
||||
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(baseSha);
|
||||
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe(
|
||||
"origin/main",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a tracked result whose final HEAD differs from the frozen SHA", async () => {
|
||||
const { localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: localRoot,
|
||||
channel: "dev",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: targetSha,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
runCommand: createRealGitUpdateRunner({ finalHead: { root: localRoot, sha: baseSha } }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("target-sha-mismatch");
|
||||
expect(result.after).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves symbolic dev target refs from the fetched remote branch", async () => {
|
||||
@@ -2275,7 +2453,10 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" });
|
||||
const result = await runWithCommand(runCommand, {
|
||||
channel: "dev",
|
||||
devTarget: { mode: "detached", ref: "main" },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(calls).toContain(`git -C ${tempDir} rev-parse refs/remotes/origin/main`);
|
||||
@@ -2359,7 +2540,10 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" });
|
||||
const result = await runWithCommand(runCommand, {
|
||||
channel: "dev",
|
||||
devTarget: { mode: "detached", ref: "main" },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(calls).toContain(`git -C ${tempDir} rev-parse --show-toplevel`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./kysely-sync.js";
|
||||
import { writeUpdateInstallReceiptRowSync } from "./restart-sentinel-store.js";
|
||||
import type { UpdateCheckResult } from "./update-check.js";
|
||||
import { parseDevUpdateTargetEnv } from "./update-dev-target.js";
|
||||
|
||||
const {
|
||||
detectRespawnSupervisorMock,
|
||||
@@ -327,13 +328,16 @@ describe("update-startup", () => {
|
||||
|
||||
function mockDevGitStatus(params?: {
|
||||
currentSha?: string;
|
||||
branch?: string | null;
|
||||
upstream?: string | null;
|
||||
upstreamSource?: "tracking" | "receipt";
|
||||
upstreamSha?: string | null;
|
||||
commitAtMs?: number | null;
|
||||
ahead?: number | null;
|
||||
behind?: number | null;
|
||||
fetchOk?: boolean;
|
||||
}) {
|
||||
const upstream = params?.upstream === undefined ? "origin/main" : params.upstream;
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
@@ -343,8 +347,13 @@ describe("update-startup", () => {
|
||||
root: "/opt/openclaw",
|
||||
sha: params?.currentSha ?? "current-sha",
|
||||
tag: null,
|
||||
branch: "main",
|
||||
upstream: params?.upstream === undefined ? "origin/main" : params.upstream,
|
||||
branch: params?.branch === undefined ? "main" : params.branch,
|
||||
upstream,
|
||||
...(params?.upstreamSource
|
||||
? { upstreamSource: params.upstreamSource }
|
||||
: upstream
|
||||
? { upstreamSource: "tracking" as const }
|
||||
: {}),
|
||||
upstreamSha: params?.upstreamSha === undefined ? "upstream-sha" : params.upstreamSha,
|
||||
commitAtMs: params?.commitAtMs ?? null,
|
||||
dirty: false,
|
||||
@@ -1141,7 +1150,11 @@ describe("update-startup", () => {
|
||||
timeoutMs: 45 * 60 * 1000,
|
||||
restartDrainTimeoutMs: 300_000,
|
||||
root: "/opt/openclaw",
|
||||
devTargetSha: "upstream-sha",
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "upstream-sha",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1165,10 +1178,18 @@ describe("update-startup", () => {
|
||||
const updateCall = vi
|
||||
.mocked(runCommandWithTimeout)
|
||||
.mock.calls.find(([argv]) => argv.includes("update"));
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.[1]).toMatchObject({
|
||||
timeoutMs: 45 * 60 * 1000,
|
||||
env: { OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha" },
|
||||
const updateOptions = updateCall?.[1];
|
||||
if (!updateOptions || typeof updateOptions === "number") {
|
||||
throw new Error("expected update command options");
|
||||
}
|
||||
expect(updateOptions.timeoutMs).toBe(45 * 60 * 1000);
|
||||
expect(parseDevUpdateTargetEnv(updateOptions.env ?? {})).toEqual({
|
||||
status: "valid",
|
||||
target: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-upstream-sha",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1186,12 +1207,85 @@ describe("update-startup", () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
const [handoffParams] = startManagedServiceUpdateHandoffMock.mock.calls[0] ?? [];
|
||||
expect(handoffParams?.env).toEqual({
|
||||
...process.env,
|
||||
OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha",
|
||||
expect(handoffParams?.devTarget).toEqual({
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "frozen-upstream-sha",
|
||||
});
|
||||
});
|
||||
|
||||
it("continues automatic dev campaigns from receipt-backed detached HEAD", async () => {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
writeUpdateInstallReceiptRowSync(db, {
|
||||
kind: "update",
|
||||
status: "ok",
|
||||
ts: Date.now() - 60_000,
|
||||
stats: {
|
||||
mode: "git",
|
||||
root: "/opt/openclaw",
|
||||
after: {
|
||||
sha: "current-sha",
|
||||
version: "1.0.0",
|
||||
upstreamRef: "origin/main",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
mockDevGitStatus({ branch: "HEAD", upstreamSource: "receipt" });
|
||||
const runAutoUpdate = createAutoUpdateSuccessMock();
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: { update: { channel: "dev", auto: { enabled: true } } },
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
activeWorkInspectors: idleActiveWorkInspectors(),
|
||||
runAutoUpdate,
|
||||
});
|
||||
|
||||
expect(checkUpdateStatus).toHaveBeenCalledWith({
|
||||
root: "/opt/openclaw",
|
||||
timeoutMs: 2500,
|
||||
fetchGit: true,
|
||||
includeRegistry: false,
|
||||
gitUpstreamFallback: { currentSha: "current-sha", upstreamRef: "origin/main" },
|
||||
});
|
||||
expect(getUpdateSchedule()?.campaign?.state).toBe("countdown");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(runAutoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
devTarget: {
|
||||
mode: "tracked",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "upstream-sha",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "ahead", git: { ahead: 1, behind: 0 } },
|
||||
{ name: "diverged", git: { ahead: 1, behind: 2 } },
|
||||
{ name: "non-main", git: { branch: "feature" } },
|
||||
{ name: "detached", git: { branch: "HEAD" } },
|
||||
])("does not announce an automatic dev campaign for a $name checkout", async ({ git }) => {
|
||||
mockDevGitStatus(git);
|
||||
const runAutoUpdate = createAutoUpdateSuccessMock();
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: { update: { channel: "dev", auto: { enabled: true } } },
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
activeWorkInspectors: idleActiveWorkInspectors(),
|
||||
runAutoUpdate,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(15 * 60_000);
|
||||
|
||||
expect(getUpdateSchedule()?.campaign).toBeUndefined();
|
||||
expect(runAutoUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not probe dev commits when the checkout is up to date", async () => {
|
||||
mockDevGitStatus({ behind: 0 });
|
||||
|
||||
@@ -1221,7 +1315,7 @@ describe("update-startup", () => {
|
||||
stats: {
|
||||
mode: "git",
|
||||
root: "/opt/openclaw",
|
||||
after: { sha: "current-sha", version: "1.0.0" },
|
||||
after: { sha: "current-sha", version: "1.0.0", upstreamRef: "origin/main" },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1282,6 +1376,18 @@ describe("update-startup", () => {
|
||||
git: { upstream: null, upstreamSha: null, ahead: null, behind: null },
|
||||
expected: { status: "unavailable", reason: "no-upstream" },
|
||||
},
|
||||
{
|
||||
name: "missing receipt-backed upstream ref",
|
||||
git: {
|
||||
branch: "HEAD",
|
||||
upstream: "origin/missing",
|
||||
upstreamSource: "receipt" as const,
|
||||
upstreamSha: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
},
|
||||
expected: { status: "unavailable", reason: "no-upstream-sha" },
|
||||
},
|
||||
{
|
||||
name: "incomparable history",
|
||||
git: { ahead: null, behind: null },
|
||||
|
||||
+58
-76
@@ -36,7 +36,10 @@ import {
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
|
||||
import { readUpdateInstallReceipt, type RestartSentinelPayload } from "./restart-sentinel.js";
|
||||
import {
|
||||
readSuccessfulGitUpdateReceipt,
|
||||
type SuccessfulGitUpdateReceipt,
|
||||
} from "./restart-sentinel.js";
|
||||
import {
|
||||
resolveGatewayRestartDeferralTimeoutMs,
|
||||
scheduleGatewaySigusr1Restart,
|
||||
@@ -45,6 +48,7 @@ import { detectRespawnSupervisor, type RespawnSupervisor } from "./supervisor-ma
|
||||
import { gatewayUpdateCampaign, type UpdateCampaignController } from "./update-campaign.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEV_BRANCH,
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
DEFAULT_PACKAGE_CHANNEL,
|
||||
@@ -57,6 +61,11 @@ import {
|
||||
type UpdateCheckResult,
|
||||
} from "./update-check.js";
|
||||
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js";
|
||||
import {
|
||||
applyDevUpdateTargetEnv,
|
||||
devUpdateTargetFromGitCampaign,
|
||||
type TrackedDevUpdateTarget,
|
||||
} from "./update-dev-target.js";
|
||||
import { updateInstallRootsMatch } from "./update-install-root.js";
|
||||
import { startManagedServiceUpdateHandoff } from "./update-managed-service-handoff.js";
|
||||
|
||||
@@ -93,14 +102,16 @@ type AutoUpdateRunResult = {
|
||||
logPath?: string;
|
||||
};
|
||||
|
||||
type AutoUpdateRunner = (params: {
|
||||
type AutoUpdateRunParams = {
|
||||
channel: "stable" | "beta" | "dev";
|
||||
timeoutMs: number;
|
||||
restartDrainTimeoutMs: number | undefined;
|
||||
root?: string;
|
||||
packageTargetVersion?: string;
|
||||
devTargetSha?: string;
|
||||
}) => Promise<AutoUpdateRunResult>;
|
||||
devTarget?: TrackedDevUpdateTarget;
|
||||
};
|
||||
|
||||
type AutoUpdateRunner = (params: AutoUpdateRunParams) => Promise<AutoUpdateRunResult>;
|
||||
|
||||
export type {
|
||||
UpdateAvailable,
|
||||
@@ -419,15 +430,9 @@ function resolveManagedAutoUpdateRestartDelayMs(supervisor: RespawnSupervisor):
|
||||
return supervisor === "systemd" ? MANAGED_AUTO_UPDATE_SYSTEMD_RESTART_GRACE_MS : 0;
|
||||
}
|
||||
|
||||
async function startManagedServiceAutoUpdateHandoff(params: {
|
||||
channel: "stable" | "beta" | "dev";
|
||||
timeoutMs: number;
|
||||
restartDrainTimeoutMs: number | undefined;
|
||||
root?: string;
|
||||
packageTargetVersion?: string;
|
||||
devTargetSha?: string;
|
||||
supervisor: RespawnSupervisor;
|
||||
}): Promise<AutoUpdateRunResult> {
|
||||
async function startManagedServiceAutoUpdateHandoff(
|
||||
params: AutoUpdateRunParams & { supervisor: RespawnSupervisor },
|
||||
): Promise<AutoUpdateRunResult> {
|
||||
const restartDelayMs = resolveManagedAutoUpdateRestartDelayMs(params.supervisor);
|
||||
const handoffId = randomUUID();
|
||||
try {
|
||||
@@ -443,14 +448,7 @@ async function startManagedServiceAutoUpdateHandoff(params: {
|
||||
restartDelayMs,
|
||||
supervisor: params.supervisor,
|
||||
handoffId,
|
||||
...(params.devTargetSha
|
||||
? {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_UPDATE_DEV_TARGET_REF: params.devTargetSha,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(params.devTarget ? { devTarget: params.devTarget } : {}),
|
||||
meta: {
|
||||
handoffId,
|
||||
note: "background auto-update",
|
||||
@@ -482,14 +480,7 @@ async function startManagedServiceAutoUpdateHandoff(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runAutoUpdateCommand(params: {
|
||||
channel: "stable" | "beta" | "dev";
|
||||
timeoutMs: number;
|
||||
restartDrainTimeoutMs: number | undefined;
|
||||
root?: string;
|
||||
packageTargetVersion?: string;
|
||||
devTargetSha?: string;
|
||||
}): Promise<AutoUpdateRunResult> {
|
||||
async function runAutoUpdateCommand(params: AutoUpdateRunParams): Promise<AutoUpdateRunResult> {
|
||||
if (isGatewayExternallySupervised()) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -507,7 +498,7 @@ async function runAutoUpdateCommand(params: {
|
||||
restartDrainTimeoutMs: params.restartDrainTimeoutMs,
|
||||
root: params.root,
|
||||
...(params.packageTargetVersion ? { packageTargetVersion: params.packageTargetVersion } : {}),
|
||||
...(params.devTargetSha ? { devTargetSha: params.devTargetSha } : {}),
|
||||
...(params.devTarget ? { devTarget: params.devTarget } : {}),
|
||||
supervisor,
|
||||
});
|
||||
}
|
||||
@@ -555,9 +546,7 @@ async function runAutoUpdateCommand(params: {
|
||||
try {
|
||||
const res = await runCommandWithTimeout(argv, {
|
||||
timeoutMs: params.timeoutMs,
|
||||
...(params.devTargetSha
|
||||
? { env: { OPENCLAW_UPDATE_DEV_TARGET_REF: params.devTargetSha } }
|
||||
: {}),
|
||||
...(params.devTarget ? { env: applyDevUpdateTargetEnv({}, params.devTarget) } : {}),
|
||||
});
|
||||
return {
|
||||
ok: res.code === 0,
|
||||
@@ -582,21 +571,26 @@ function clearAutoState(nextState: UpdateCheckState): void {
|
||||
}
|
||||
|
||||
async function resolveStartupInstallStatus(fetchGit: boolean) {
|
||||
const resolvedRoot = await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const [status, installReceipt] = await Promise.all([
|
||||
checkUpdateStatus({
|
||||
root: resolvedRoot,
|
||||
timeoutMs: 2500,
|
||||
fetchGit,
|
||||
includeRegistry: false,
|
||||
const [root, installReceipt] = await Promise.all([
|
||||
resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
readUpdateInstallReceipt(),
|
||||
readSuccessfulGitUpdateReceipt(),
|
||||
]);
|
||||
return { root: resolvedRoot, status, installReceipt };
|
||||
const gitUpstreamFallback =
|
||||
installReceipt?.upstreamRef && root && updateInstallRootsMatch(root, installReceipt.root)
|
||||
? { currentSha: installReceipt.sha, upstreamRef: installReceipt.upstreamRef }
|
||||
: undefined;
|
||||
const status = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: 2500,
|
||||
fetchGit,
|
||||
includeRegistry: false,
|
||||
...(gitUpstreamFallback ? { gitUpstreamFallback } : {}),
|
||||
});
|
||||
return { root, status, installReceipt };
|
||||
}
|
||||
|
||||
type GitScheduleStatus = NonNullable<NonNullable<UpdateScheduleState["install"]>["git"]>;
|
||||
@@ -613,27 +607,21 @@ function gitCommitsMatch(left: string, right: string): boolean {
|
||||
|
||||
function resolveGitInstalledAtMs(
|
||||
git: NonNullable<UpdateCheckResult["git"]>,
|
||||
installReceipt: RestartSentinelPayload | null,
|
||||
installReceipt: SuccessfulGitUpdateReceipt | null,
|
||||
root: string | null,
|
||||
): number | undefined {
|
||||
const receiptSha = installReceipt?.stats?.after?.sha;
|
||||
const receiptRoot = installReceipt?.stats?.root;
|
||||
return installReceipt?.kind === "update" &&
|
||||
installReceipt.status === "ok" &&
|
||||
installReceipt.stats?.mode === "git" &&
|
||||
typeof receiptSha === "string" &&
|
||||
typeof receiptRoot === "string" &&
|
||||
return installReceipt &&
|
||||
root !== null &&
|
||||
updateInstallRootsMatch(root, receiptRoot) &&
|
||||
updateInstallRootsMatch(root, installReceipt.root) &&
|
||||
git.sha &&
|
||||
gitCommitsMatch(receiptSha, git.sha)
|
||||
? installReceipt.ts
|
||||
gitCommitsMatch(installReceipt.sha, git.sha)
|
||||
? installReceipt.installedAtMs
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveGitScheduleStatus(
|
||||
update: UpdateCheckResult,
|
||||
installReceipt: RestartSentinelPayload | null,
|
||||
installReceipt: SuccessfulGitUpdateReceipt | null,
|
||||
root: string | null,
|
||||
): GitScheduleStatus | undefined {
|
||||
if (update.installKind !== "git") {
|
||||
@@ -684,7 +672,7 @@ function withInstallStatus(
|
||||
schedule: UpdateScheduleState,
|
||||
update: UpdateCheckResult,
|
||||
includeGitStatus: boolean,
|
||||
installReceipt: RestartSentinelPayload | null,
|
||||
installReceipt: SuccessfulGitUpdateReceipt | null,
|
||||
root: string | null,
|
||||
): UpdateScheduleState {
|
||||
const git = includeGitStatus ? resolveGitScheduleStatus(update, installReceipt, root) : undefined;
|
||||
@@ -756,19 +744,18 @@ async function resolveDevGitCommits(params: {
|
||||
}
|
||||
|
||||
async function runCampaignUpdate(params: {
|
||||
identity: string;
|
||||
channel: "stable" | "beta" | "dev";
|
||||
version: string;
|
||||
tag: string;
|
||||
forced: boolean;
|
||||
root?: string;
|
||||
devTargetSha?: string;
|
||||
devTarget?: TrackedDevUpdateTarget;
|
||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
runAuto: AutoUpdateRunner;
|
||||
}): Promise<"handoff" | "applied" | "failed"> {
|
||||
const attemptAt = resolveUpdateCheckNowMs(Date.now());
|
||||
const attemptState = await readState();
|
||||
attemptState.autoLastAttemptVersion = params.identity;
|
||||
attemptState.autoLastAttemptVersion = params.version;
|
||||
attemptState.autoLastAttemptAt = resolveUpdateCheckTimestamp(attemptAt);
|
||||
await writeState(attemptState);
|
||||
|
||||
@@ -778,7 +765,7 @@ async function runCampaignUpdate(params: {
|
||||
restartDrainTimeoutMs: resolveGatewayRestartDeferralTimeoutMs(),
|
||||
...(params.root ? { root: params.root } : {}),
|
||||
...(params.channel === "dev" ? {} : { packageTargetVersion: params.version }),
|
||||
...(params.devTargetSha ? { devTargetSha: params.devTargetSha } : {}),
|
||||
...(params.devTarget ? { devTarget: params.devTarget } : {}),
|
||||
});
|
||||
if (outcome.ok && outcome.reason === CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON) {
|
||||
params.log.info("auto-update handoff started", {
|
||||
@@ -793,7 +780,7 @@ async function runCampaignUpdate(params: {
|
||||
}
|
||||
if (outcome.ok) {
|
||||
const successState = await readState();
|
||||
successState.autoLastSuccessVersion = params.identity;
|
||||
successState.autoLastSuccessVersion = params.version;
|
||||
successState.autoLastSuccessAt = resolveUpdateCheckTimestamp(Date.now());
|
||||
await writeState(successState);
|
||||
params.log.info("auto-update applied", {
|
||||
@@ -823,14 +810,7 @@ export async function runGatewayUpdateCheck(params: {
|
||||
onUpdateScheduleChange?: (schedule: UpdateScheduleState) => void;
|
||||
activeWorkInspectors?: Partial<GatewayActiveWorkInspectors>;
|
||||
updateCampaign?: UpdateCampaignController;
|
||||
runAutoUpdate?: (params: {
|
||||
channel: "stable" | "beta" | "dev";
|
||||
timeoutMs: number;
|
||||
restartDrainTimeoutMs: number | undefined;
|
||||
root?: string;
|
||||
packageTargetVersion?: string;
|
||||
devTargetSha?: string;
|
||||
}) => Promise<AutoUpdateRunResult>;
|
||||
runAutoUpdate?: AutoUpdateRunner;
|
||||
}): Promise<void> {
|
||||
if (shouldSkipCheck(Boolean(params.allowInTests))) {
|
||||
return;
|
||||
@@ -1144,7 +1124,11 @@ export async function runGatewayUpdateCheck(params: {
|
||||
reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
|
||||
});
|
||||
}
|
||||
if (shouldRunAutoUpdate) {
|
||||
const hasTrackedMain = git.branch === DEV_BRANCH && git.upstreamSource === "tracking";
|
||||
const hasReceiptBackedDetachedHead = git.branch === "HEAD" && git.upstreamSource === "receipt";
|
||||
const canRunTrackedDevCampaign =
|
||||
(hasTrackedMain || hasReceiptBackedDetachedHead) && git.ahead === 0;
|
||||
if (shouldRunAutoUpdate && canRunTrackedDevCampaign) {
|
||||
const lastAttemptAt = state.autoLastAttemptAt ? Date.parse(state.autoLastAttemptAt) : null;
|
||||
const recentAttempt =
|
||||
state.autoLastAttemptVersion === upstreamSha &&
|
||||
@@ -1159,13 +1143,12 @@ export async function runGatewayUpdateCheck(params: {
|
||||
onChange: onCampaignChange,
|
||||
apply: async ({ forced }) =>
|
||||
await runCampaignUpdate({
|
||||
identity: upstreamSha,
|
||||
channel: "dev",
|
||||
version: upstreamSha,
|
||||
tag: "dev",
|
||||
forced,
|
||||
root: root ?? status.root ?? undefined,
|
||||
devTargetSha: target.upstreamSha,
|
||||
devTarget: devUpdateTargetFromGitCampaign(target),
|
||||
log: params.log,
|
||||
runAuto,
|
||||
}),
|
||||
@@ -1312,7 +1295,6 @@ export async function runGatewayUpdateCheck(params: {
|
||||
onChange: onCampaignChange,
|
||||
apply: async ({ forced }) =>
|
||||
await runCampaignUpdate({
|
||||
identity: resolvedVersion,
|
||||
channel,
|
||||
version: resolvedVersion,
|
||||
tag,
|
||||
|
||||
Reference in New Issue
Block a user