mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
e27433c22e
commit
058a72fe66
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user