fix(update): bind managed handoffs to exact targets (#128868)

* fix(update): bind managed handoffs to exact targets

* fix(update): preserve campaigns on target mismatch

* fix(update): fence active campaign updates
This commit is contained in:
Peter Steinberger
2026-08-24 17:01:11 -07:00
committed by GitHub
parent e27433c22e
commit 058a72fe66
11 changed files with 484 additions and 56 deletions
@@ -20461,6 +20461,7 @@ public struct UpdateRunParams: Codable, Sendable {
public let continuationmessage: String?
public let restartdelayms: Int?
public let timeoutms: Int?
public let target: [String: AnyCodable]?
public init(
sessionkey: String? = nil,
@@ -20468,7 +20469,8 @@ public struct UpdateRunParams: Codable, Sendable {
note: String? = nil,
continuationmessage: String? = nil,
restartdelayms: Int? = nil,
timeoutms: Int? = nil)
timeoutms: Int? = nil,
target: [String: AnyCodable]? = nil)
{
self.sessionkey = sessionkey
self.deliverycontext = deliverycontext
@@ -20476,6 +20478,7 @@ public struct UpdateRunParams: Codable, Sendable {
self.continuationmessage = continuationmessage
self.restartdelayms = restartdelayms
self.timeoutms = timeoutms
self.target = target
}
private enum CodingKeys: String, CodingKey {
@@ -20485,6 +20488,7 @@ public struct UpdateRunParams: Codable, Sendable {
case continuationmessage = "continuationMessage"
case restartdelayms = "restartDelayMs"
case timeoutms = "timeoutMs"
case target
}
}
@@ -6,6 +6,7 @@ import {
UpdateAvailableSchema,
UpdateHoldParamsSchema,
UpdateHoldResultSchema,
UpdateRunParamsSchema,
UpdateScheduleStateSchema,
UpdateStatusParamsSchema,
UpdateStatusResultSchema,
@@ -56,6 +57,29 @@ describe("ConfigSchemaLookupResultSchema", () => {
});
describe("update protocol schemas", () => {
it("accepts only closed, exact tracked Git targets for update.run", () => {
const target = {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "1234567890abcdef1234567890abcdef12345678",
};
expect(Value.Check(UpdateRunParamsSchema, {})).toBe(true);
expect(Value.Check(UpdateRunParamsSchema, { target })).toBe(true);
for (const invalidTarget of [
{ ...target, upstreamSha: "1234567" },
{ ...target, upstreamSha: "g".repeat(40) },
{ ...target, upstreamRef: "" },
{ ...target, upstreamRef: "origin/main branch" },
{ ...target, upstreamRef: "origin/main\u0000" },
{ ...target, kind: "package" },
{ ...target, extra: true },
]) {
expect(Value.Check(UpdateRunParamsSchema, { target: invalidTarget })).toBe(false);
}
});
it("accepts an optional explicit checkout refresh", () => {
expect(Value.Check(UpdateStatusParamsSchema, {})).toBe(true);
expect(Value.Check(UpdateStatusParamsSchema, { refreshCheckout: true })).toBe(true);
@@ -192,6 +192,16 @@ export const UpdateRunParamsSchema = closedObject({
continuationMessage: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
target: Type.Optional(
closedObject({
kind: Type.Literal("git"),
upstreamRef: Type.String({
minLength: 1,
pattern: "^[^\\s\\u0000-\\u001f\\u007f-\\u009f]+$",
}),
upstreamSha: Type.String({ pattern: "^[a-fA-F0-9]{40}$" }),
}),
),
});
/** UI metadata attached to config schema paths. */
@@ -13,9 +13,10 @@ let currentCampaignId: string | undefined;
let updateSchedule: UpdateScheduleState | null;
let updateChannel: "stable" | "beta" | "dev" | null;
const versionMock = vi.hoisted(() => ({ value: "1.0.0" }));
type UpdateCampaignAdoption = NonNullable<ReturnType<UpdateCampaignController["adopt"]>>;
type UpdateCampaignAdoption = ReturnType<UpdateCampaignController["adopt"]>;
const adoptCampaignMock = vi.fn<() => UpdateCampaignAdoption | undefined>(() => ({
const adoptCampaignMock = vi.fn<() => UpdateCampaignAdoption>(() => ({
status: "adopted",
campaignId: "campaign-1",
target: { kind: "package", version: "2.0.0" },
}));
@@ -173,6 +174,7 @@ beforeEach(() => {
versionMock.value = "1.0.0";
adoptCampaignMock.mockReset();
adoptCampaignMock.mockReturnValue({
status: "adopted",
campaignId: "campaign-1",
target: { kind: "package", version: "2.0.0" },
});
@@ -205,6 +207,7 @@ beforeEach(() => {
function setDevCampaignSchedule(upstreamSha = "frozen-upstream-sha"): void {
updateChannel = "dev";
adoptCampaignMock.mockReturnValue({
status: "adopted",
campaignId: "campaign-1",
target: {
kind: "git",
@@ -233,6 +236,31 @@ function setDevCampaignSchedule(upstreamSha = "frozen-upstream-sha"): void {
};
}
function mockGitInstallStatus(upstreamSha: string, upstreamRef = "origin/main"): void {
const root = "/tmp/openclaw";
initializeGatewayUpdateStatusMock.mockResolvedValueOnce({
root,
status: {
root,
installKind: "git",
packageManager: "pnpm",
git: {
root,
sha: "0".repeat(40),
tag: null,
branch: "main",
upstream: upstreamRef,
upstreamSha,
dirty: false,
ahead: 0,
behind: 1,
fetchOk: true,
},
},
installReceipt: null,
});
}
function mockPackageInstallSurface(kind: "global" | "package-root"): void {
const root = "/tmp/openclaw";
initializeGatewayUpdateStatusMock.mockResolvedValueOnce({
@@ -247,14 +275,17 @@ function mockPackageInstallSurface(kind: "global" | "package-root"): void {
);
}
async function invokeUpdateRun(): Promise<void> {
async function invokeUpdateRun(
params: Record<string, unknown> = {},
respond: (ok: boolean, response?: unknown) => void = () => undefined,
): Promise<void> {
const { updateHandlers } = await import("./update.js");
await expectDefined(
updateHandlers["update.run"],
'updateHandlers["update.run"] test invariant',
)({
params: {},
respond: () => undefined,
params,
respond,
client: {
connId: "conn-1",
clientIp: "127.0.0.1",
@@ -267,6 +298,20 @@ async function invokeUpdateRun(): Promise<void> {
} as never);
}
async function captureUpdateRun(params: Record<string, unknown>) {
let response: { ok?: boolean; result?: { status?: string; reason?: string } } | undefined;
await invokeUpdateRun(params, (_ok, payload) => {
response = payload as typeof response;
});
return response;
}
function expectNoUpdateMutation(): void {
expect(runGatewayUpdatePreflightMock).not.toHaveBeenCalled();
expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled();
expect(runGatewayUpdateMock).not.toHaveBeenCalled();
}
describe("update.run campaign ownership", () => {
it("pins a directly applied package campaign to its announced version", async () => {
updateChannel = "beta";
@@ -309,7 +354,7 @@ describe("update.run campaign ownership", () => {
it("keeps a plain package update on the moving configured channel", async () => {
updateChannel = "beta";
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
mockPackageInstallSurface("package-root");
await invokeUpdateRun();
@@ -321,7 +366,7 @@ describe("update.run campaign ownership", () => {
});
it("uses the prepared Git checkout instead of process artifacts", async () => {
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
initializeGatewayUpdateStatusMock.mockResolvedValueOnce({
root: "/tmp/openclaw-source",
status: {
@@ -358,7 +403,7 @@ describe("update.run campaign ownership", () => {
});
it("rejects a missing prepared root without scanning the process working directory", async () => {
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
initializeGatewayUpdateStatusMock.mockResolvedValueOnce({
root: null,
status: { root: null, installKind: "unknown", packageManager: "unknown" },
@@ -405,9 +450,183 @@ describe("update.run campaign ownership", () => {
);
});
it("rejects an explicit commit that conflicts with the adopted Git campaign before mutation", async () => {
const campaignSha = "1234567890abcdef1234567890abcdef12345678";
setDevCampaignSchedule(campaignSha);
mockGitInstallStatus(campaignSha);
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
adoptCampaignMock.mockReturnValueOnce({ status: "mismatch" });
const response = await captureUpdateRun({
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "abcdef1234567890abcdef1234567890abcdef12",
},
});
expect(response?.result).toMatchObject({
status: "error",
reason: "update-target-campaign-mismatch",
});
expectNoUpdateMutation();
expect(clearCampaignMock).not.toHaveBeenCalled();
await invokeUpdateRun();
expect(adoptCampaignMock).toHaveBeenCalledTimes(2);
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
devTarget: { mode: "tracked", upstreamRef: "origin/main", upstreamSha: campaignSha },
}),
);
});
it("coalesces an explicit commit matching the adopted Git campaign", async () => {
const upstreamSha = "1234567890abcdef1234567890abcdef12345678";
setDevCampaignSchedule(upstreamSha);
mockGitInstallStatus(upstreamSha);
await invokeUpdateRun({ target: { kind: "git", upstreamRef: "origin/main", upstreamSha } });
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
devTarget: { mode: "tracked", upstreamRef: "origin/main", upstreamSha },
}),
);
});
describe("explicit Git target binding", () => {
const requestTarget = {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "1234567890abcdef1234567890abcdef12345678",
};
const newerUpstreamSha = "abcdef1234567890abcdef1234567890abcdef12";
const trackedTarget = {
mode: "tracked",
upstreamRef: requestTarget.upstreamRef,
upstreamSha: requestTarget.upstreamSha,
};
beforeEach(() => {
updateChannel = "dev";
adoptCampaignMock.mockReturnValue({ status: "absent" });
});
it.each([
{ name: "matching", campaignSha: requestTarget.upstreamSha },
{ name: "conflicting", campaignSha: newerUpstreamSha },
])(
"rejects a $name explicit target while its campaign is applying",
async ({ campaignSha }) => {
setDevCampaignSchedule(campaignSha);
mockGitInstallStatus(campaignSha);
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
adoptCampaignMock.mockReturnValueOnce({ status: "applying" });
const response = await captureUpdateRun({ target: requestTarget });
expect(response).toMatchObject({
ok: false,
result: { status: "error", reason: "update-campaign-applying" },
});
expect(adoptCampaignMock).toHaveBeenCalledWith(trackedTarget);
expectNoUpdateMutation();
expect(clearCampaignMock).not.toHaveBeenCalled();
},
);
it("keeps the requested commit through managed preflight and handoff after upstream advances", async () => {
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
mockGitInstallStatus(newerUpstreamSha);
const response = await captureUpdateRun({ target: requestTarget });
expect(runGatewayUpdatePreflightMock).toHaveBeenCalledWith(
"/tmp/openclaw",
undefined,
trackedTarget,
);
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
expect.objectContaining({ devTarget: trackedTarget }),
);
expect(response?.ok).toBe(true);
});
it("passes the requested commit to a direct Git update", async () => {
mockGitInstallStatus(newerUpstreamSha);
await invokeUpdateRun({ target: requestTarget });
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({ devTarget: trackedTarget }),
);
});
it.each([
{ name: "short SHA", target: { ...requestTarget, upstreamSha: "1234567" } },
{ name: "nonhex SHA", target: { ...requestTarget, upstreamSha: "g".repeat(40) } },
{ name: "unsafe upstream", target: { ...requestTarget, upstreamRef: "origin/main branch" } },
{ name: "wrong kind", target: { ...requestTarget, kind: "package" } },
{ name: "non-object", target: "origin/main" },
])("rejects malformed $name before any update mutation", async ({ target }) => {
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
const response = await captureUpdateRun({ target });
expect(response?.result).toMatchObject({ status: "error", reason: "invalid-update-target" });
expectNoUpdateMutation();
expect(adoptCampaignMock).not.toHaveBeenCalled();
});
it("rejects a target for another Git upstream before update mutation", async () => {
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
mockGitInstallStatus(newerUpstreamSha, "upstream/main");
const response = await captureUpdateRun({ target: requestTarget });
expect(response?.result).toMatchObject({
status: "error",
reason: "update-target-upstream-mismatch",
});
expectNoUpdateMutation();
expect(adoptCampaignMock).not.toHaveBeenCalled();
});
it("rejects an exact Git target outside the dev channel before update mutation", async () => {
updateChannel = "stable";
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
mockGitInstallStatus(newerUpstreamSha);
const response = await captureUpdateRun({ target: requestTarget });
expect(response?.result).toMatchObject({
status: "error",
reason: "unsupported-update-target",
});
expectNoUpdateMutation();
expect(adoptCampaignMock).not.toHaveBeenCalled();
});
it("rejects an exact Git target on a package install before update mutation", async () => {
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
mockPackageInstallSurface("global");
const response = await captureUpdateRun({ target: requestTarget });
expect(response?.result).toMatchObject({
status: "error",
reason: "unsupported-update-target",
});
expectNoUpdateMutation();
expect(adoptCampaignMock).not.toHaveBeenCalled();
});
});
it("does not pin a plain dev update without a campaign", async () => {
updateChannel = "dev";
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
await invokeUpdateRun();
@@ -418,7 +637,7 @@ describe("update.run campaign ownership", () => {
it("does not add a pin environment to a non-campaign managed handoff", async () => {
updateChannel = "dev";
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
await withEnvAsync({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, invokeUpdateRun);
@@ -438,9 +657,9 @@ describe("update.run campaign ownership", () => {
});
});
it("does not clear a campaign that update.run did not adopt", async () => {
it("continues without an explicit target when no campaign can be adopted", async () => {
setDevCampaignSchedule();
adoptCampaignMock.mockReturnValueOnce(undefined);
adoptCampaignMock.mockReturnValueOnce({ status: "absent" });
await invokeUpdateRun();
@@ -451,6 +670,21 @@ describe("update.run campaign ownership", () => {
);
});
it("rejects an untargeted update while a campaign is applying", async () => {
setDevCampaignSchedule();
adoptCampaignMock.mockReturnValueOnce({ status: "applying" });
const response = await captureUpdateRun({});
expect(response).toMatchObject({
ok: false,
result: { status: "error", reason: "update-campaign-applying" },
});
expectNoUpdateMutation();
expect(getCampaignStateMock).not.toHaveBeenCalled();
expect(clearCampaignMock).not.toHaveBeenCalled();
});
it("does not clear a replacement campaign when the adopted update fails", async () => {
const deferredUpdate = createDeferred<UpdateRunResult>();
runGatewayUpdateMock.mockReturnValueOnce(deferredUpdate.promise);
+4 -4
View File
@@ -44,10 +44,10 @@ const getUpdateScheduleMock = vi.fn<
() => import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState | null
>(() => null);
const refreshGatewayUpdateStatusMock = vi.fn(async () => {});
type UpdateCampaignAdoption = NonNullable<
ReturnType<import("../../infra/update-campaign.js").UpdateCampaignController["adopt"]>
type UpdateCampaignAdoption = ReturnType<
import("../../infra/update-campaign.js").UpdateCampaignController["adopt"]
>;
const adoptUpdateCampaignMock = vi.fn<() => UpdateCampaignAdoption | undefined>(() => undefined);
const adoptUpdateCampaignMock = vi.fn<() => UpdateCampaignAdoption>(() => ({ status: "absent" }));
const readConfigFileSnapshotMock = vi.fn<() => Promise<ConfigFileSnapshot>>();
type ManagedServiceUpdateHandoffResult = Awaited<
ReturnType<
@@ -235,7 +235,7 @@ beforeEach(() => {
getUpdateScheduleMock.mockReset();
getUpdateScheduleMock.mockReturnValue(null);
adoptUpdateCampaignMock.mockReset();
adoptUpdateCampaignMock.mockReturnValue(undefined);
adoptUpdateCampaignMock.mockReturnValue({ status: "absent" });
readConfigFileSnapshotMock.mockReset();
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
+62 -21
View File
@@ -31,7 +31,7 @@ import {
resolveEffectiveUpdateChannel,
} from "../../infra/update-channels.js";
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js";
import { devUpdateTargetFromGitCampaign } from "../../infra/update-dev-target.js";
import { devUpdateTargetFromGitTarget } from "../../infra/update-dev-target.js";
import { resolveUpdateInstallRoot } from "../../infra/update-install-root.js";
import {
buildManagedServiceHandoffUnavailableMessage,
@@ -205,23 +205,7 @@ export const updateHandlers: GatewayRequestHandlers = {
if (!assertValidParams(params, validateUpdateRunParams, "update.run", respond)) {
return;
}
const adoptedCampaign = gatewayUpdateCampaign.adopt();
const adoptedCampaignId = adoptedCampaign?.campaignId;
const adoptedDevTarget =
adoptedCampaign?.target.kind === "git"
? devUpdateTargetFromGitCampaign(adoptedCampaign.target)
: undefined;
const adoptedPackageTargetVersion =
adoptedCampaign?.target.kind === "package"
? adoptedCampaign.target.version.trim() || undefined
: undefined;
const actor = resolveControlPlaneActor(client);
if (adoptedCampaign) {
context?.logGateway?.info(
`update.run adopted campaign ${adoptedCampaign.campaignId} ${formatControlPlaneActor(actor)}`,
{ target: adoptedCampaign.target },
);
}
const {
sessionKey,
deliveryContext: requestedDeliveryContext,
@@ -249,6 +233,7 @@ export const updateHandlers: GatewayRequestHandlers = {
| null = null;
let managedHandoffRestart: ReturnType<typeof scheduleGatewaySigusr1Restart> | null = null;
let ownsManagedServiceHandoff = true;
let adoptedCampaignId: string | undefined;
const sentinelMeta: UpdateRestartSentinelMeta = {
...(sessionKey ? { sessionKey } : {}),
...(deliveryContext ? { deliveryContext } : {}),
@@ -272,19 +257,75 @@ export const updateHandlers: GatewayRequestHandlers = {
installKind: status.installKind,
git: status.git,
}).channel;
const requestedTarget = params.target;
const explicitDevTarget =
isRecord(requestedTarget) &&
requestedTarget.kind === "git" &&
typeof requestedTarget.upstreamRef === "string" &&
/^[^\s\p{Cc}]+$/u.test(requestedTarget.upstreamRef) &&
typeof requestedTarget.upstreamSha === "string" &&
/^[a-f\d]{40}$/iu.test(requestedTarget.upstreamSha)
? devUpdateTargetFromGitTarget({
upstreamRef: requestedTarget.upstreamRef,
upstreamSha: requestedTarget.upstreamSha,
})
: undefined;
let targetFailureReason =
requestedTarget !== undefined && !explicitDevTarget
? "invalid-update-target"
: explicitDevTarget && (installSurface.kind !== "git" || effectiveChannel !== "dev")
? "unsupported-update-target"
: explicitDevTarget && explicitDevTarget.upstreamRef !== status.git?.upstream
? "update-target-upstream-mismatch"
: undefined;
const adoption = targetFailureReason
? undefined
: gatewayUpdateCampaign.adopt(explicitDevTarget);
if (adoption?.status === "mismatch") {
targetFailureReason = "update-target-campaign-mismatch";
} else if (adoption?.status === "applying") {
targetFailureReason = "update-campaign-applying";
}
const adoptedCampaign = adoption?.status === "adopted" ? adoption : undefined;
adoptedCampaignId = adoptedCampaign?.campaignId;
const adoptedDevTarget =
adoptedCampaign?.target.kind === "git"
? devUpdateTargetFromGitTarget(adoptedCampaign.target)
: undefined;
const adoptedPackageTargetVersion =
adoptedCampaign?.target.kind === "package"
? adoptedCampaign.target.version.trim() || undefined
: undefined;
if (adoptedCampaign) {
context?.logGateway?.info(
`update.run adopted campaign ${adoptedCampaign.campaignId} ${formatControlPlaneActor(actor)}`,
{ target: adoptedCampaign.target },
);
}
const devTarget = explicitDevTarget ?? adoptedDevTarget;
const supervisor = detectRespawnSupervisor(process.env, process.platform, {
includeLinuxOpenClawGatewayServiceMarker: true,
});
const requiresManagedServiceHandoff =
installSurface.kind === "global" || (installSurface.kind === "git" && supervisor !== null);
const managedGitPreflightFailure =
!targetFailureReason &&
installSurface.kind === "git" &&
effectiveChannel === "dev" &&
supervisor &&
!isGatewayExternallySupervised()
? await runGatewayUpdatePreflight(installRoot, timeoutMs, adoptedDevTarget)
? await runGatewayUpdatePreflight(installRoot, timeoutMs, devTarget)
: undefined;
if (installSurface.kind === "missing") {
if (targetFailureReason) {
result = {
status: "error",
mode: installSurface.mode,
...(installRoot ? { root: installRoot } : {}),
reason: targetFailureReason,
steps: [],
durationMs: 0,
};
} else if (installSurface.kind === "missing") {
result = {
status: "error",
mode: "unknown",
@@ -366,7 +407,7 @@ export const updateHandlers: GatewayRequestHandlers = {
restartDrainTimeoutMs: resolveGatewayRestartDeferralTimeoutMs(),
...(handoffChannel ? { channel: handoffChannel } : {}),
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
...(devTarget ? { devTarget } : {}),
restartDelayMs: managedRestartDelayMs,
meta: sentinelMeta,
handoffId,
@@ -483,7 +524,7 @@ export const updateHandlers: GatewayRequestHandlers = {
? effectiveChannel
: (configChannel ?? undefined),
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
...(devTarget ? { devTarget } : {}),
allowGatewayServiceRepair: false,
allowGatewayActivation: false,
});
+106
View File
@@ -154,6 +154,7 @@ describe("UpdateCampaignController", () => {
const controller = createController();
const apply = vi.fn(async () => "applied" as const);
expect(controller.adopt()).toEqual({ status: "absent" });
controller.announce({
target: { kind: "package", version: "2.0.0" },
inspect: createInspectors(() => 0),
@@ -161,6 +162,7 @@ describe("UpdateCampaignController", () => {
onChange: vi.fn(),
});
expect(controller.adopt()).toEqual({
status: "adopted",
campaignId: "campaign-1",
target: { kind: "package", version: "2.0.0" },
});
@@ -170,6 +172,109 @@ describe("UpdateCampaignController", () => {
expect(apply).not.toHaveBeenCalled();
});
it.each([
{
name: "a different Git commit",
target: {
kind: "git" as const,
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
commitsBehind: 3,
},
requested: {
mode: "tracked" as const,
upstreamRef: "origin/main",
upstreamSha: "different-sha",
},
matching: {
mode: "tracked" as const,
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
},
},
{
name: "a different Git upstream",
target: {
kind: "git" as const,
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
commitsBehind: 3,
},
requested: {
mode: "tracked" as const,
upstreamRef: "upstream/main",
upstreamSha: "frozen-sha",
},
matching: {
mode: "tracked" as const,
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
},
},
{
name: "a package campaign",
target: { kind: "package" as const, version: "2.0.0" },
requested: {
mode: "tracked" as const,
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
},
matching: undefined,
},
])("keeps $name waiting after mismatched adoption", ({ target, requested, matching }) => {
const controller = createController();
const apply = vi.fn(async () => "applied" as const);
const onChange = vi.fn();
controller.announce({ target, inspect: createInspectors(() => 1), apply, onChange });
expect(controller.adopt(requested)).toEqual({ status: "mismatch" });
expect(controller.getState()).toMatchObject({ id: "campaign-1", state: "waiting-for-idle" });
expect(onChange).toHaveBeenCalledOnce();
expect(apply).not.toHaveBeenCalled();
expect(controller.adopt(matching)).toMatchObject({
status: "adopted",
campaignId: "campaign-1",
target,
});
expect(controller.getState()?.state).toBe("applying");
});
it.each(["untargeted", "matching", "conflicting"] as const)(
"keeps an applying campaign unchanged for a %s adoption",
async (targetRelation) => {
const controller = createController();
const apply = vi.fn(async () => "applied" as const);
const onChange = vi.fn();
controller.announce({
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
commitsBehind: 3,
},
inspect: createInspectors(() => 0),
apply,
onChange,
});
await vi.advanceTimersByTimeAsync(60_000);
const transitionCount = onChange.mock.calls.length;
const requestedTarget =
targetRelation === "untargeted"
? undefined
: {
mode: "tracked" as const,
upstreamRef: "origin/main",
upstreamSha: targetRelation === "matching" ? "frozen-sha" : "different-sha",
};
expect(controller.adopt(requestedTarget)).toEqual({ status: "applying" });
expect(controller.getState()).toMatchObject({ id: "campaign-1", state: "applying" });
expect(onChange).toHaveBeenCalledTimes(transitionCount);
expect(apply).toHaveBeenCalledOnce();
},
);
it("holds a waiting campaign once and shifts its hard deadline", async () => {
const controller = createController();
const apply = vi.fn(async () => "applied" as const);
@@ -228,6 +333,7 @@ describe("UpdateCampaignController", () => {
expect(apply).not.toHaveBeenCalled();
expect(controller.adopt()).toMatchObject({
status: "adopted",
campaignId: "campaign-1",
target: { kind: "package", version: "2.0.0" },
});
+21 -8
View File
@@ -5,6 +5,7 @@ import {
createGatewayActiveWorkSnapshot,
type GatewayActiveWorkInspectors,
} from "./gateway-active-work.js";
import type { TrackedDevUpdateTarget } from "./update-dev-target.js";
const CAMPAIGN_FORCE_DELAY_MS = 15 * 60_000;
const CAMPAIGN_COUNTDOWN_MS = 60_000;
@@ -14,10 +15,11 @@ const CAMPAIGN_POLL_MS = 5_000;
type UpdateCampaignState = NonNullable<UpdateScheduleState["campaign"]>;
type UpdateCampaignTarget = NonNullable<UpdateScheduleState["target"]>;
type UpdateCampaignAdoption = {
campaignId: string;
target: UpdateCampaignTarget;
};
type UpdateCampaignAdoptionResult =
| { status: "absent" }
| { status: "applying" }
| { status: "mismatch" }
| { status: "adopted"; campaignId: string; target: UpdateCampaignTarget };
type UpdateCampaignAnnouncement = {
target: UpdateCampaignTarget;
@@ -102,14 +104,25 @@ export class UpdateCampaignController {
}
}
adopt(): UpdateCampaignAdoption | undefined {
adopt(expectedTarget?: TrackedDevUpdateTarget): UpdateCampaignAdoptionResult {
const campaign = this.campaign;
const target = this.target;
if (!campaign || !target || campaign.state === "applying") {
return undefined;
if (!campaign || !target) {
return { status: "absent" };
}
if (campaign.state === "applying") {
return { status: "applying" };
}
if (
expectedTarget &&
(target.kind !== "git" ||
target.upstreamRef !== expectedTarget.upstreamRef ||
target.upstreamSha !== expectedTarget.upstreamSha)
) {
return { status: "mismatch" };
}
this.beginApplying(false, false);
return { campaignId: campaign.id, target: { ...target } };
return { status: "adopted", campaignId: campaign.id, target: { ...target } };
}
hold(durationMs = CAMPAIGN_HOLD_MS): boolean {
+2 -4
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applyDevUpdateTargetEnv,
devUpdateTargetFromGitCampaign,
devUpdateTargetFromGitTarget,
parseDevUpdateTargetEnv,
resolveDevUpdateTargetRevision,
} from "./update-dev-target.js";
@@ -38,11 +38,9 @@ describe("dev update target environment", () => {
});
it("projects campaign targets and resolves both target modes", () => {
const tracked = devUpdateTargetFromGitCampaign({
kind: "git",
const tracked = devUpdateTargetFromGitTarget({
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
commitsBehind: 2,
});
expect(tracked).toEqual({
+2 -4
View File
@@ -1,6 +1,5 @@
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:";
@@ -11,7 +10,6 @@ export type DevUpdateTarget =
| { 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" }
@@ -66,8 +64,8 @@ export function resolveDevUpdateTargetRevision(target: DevUpdateTarget): string
return target.mode === "tracked" ? target.upstreamSha : target.ref;
}
export function devUpdateTargetFromGitCampaign(
target: GitUpdateCampaignTarget,
export function devUpdateTargetFromGitTarget(
target: Pick<TrackedDevUpdateTarget, "upstreamRef" | "upstreamSha">,
): TrackedDevUpdateTarget {
return {
mode: "tracked",
+2 -2
View File
@@ -64,7 +64,7 @@ import {
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js";
import {
applyDevUpdateTargetEnv,
devUpdateTargetFromGitCampaign,
devUpdateTargetFromGitTarget,
type TrackedDevUpdateTarget,
} from "./update-dev-target.js";
import { updateInstallRootsMatch } from "./update-install-root.js";
@@ -1121,7 +1121,7 @@ export async function runGatewayUpdateCheck(params: {
tag: "dev",
forced,
root: root ?? status.root ?? undefined,
devTarget: devUpdateTargetFromGitCampaign(target),
devTarget: devUpdateTargetFromGitTarget(target),
log: params.log,
runAuto,
}),