diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ff70eecd437d..0385ac2559cb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 } } diff --git a/packages/gateway-protocol/src/schema/config.test.ts b/packages/gateway-protocol/src/schema/config.test.ts index ca7b45a15b87..39ce3b2a0816 100644 --- a/packages/gateway-protocol/src/schema/config.test.ts +++ b/packages/gateway-protocol/src/schema/config.test.ts @@ -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); diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index fa9f81e1674d..9b6a4c48035d 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -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. */ diff --git a/src/gateway/server-methods/update-run-campaign.test.ts b/src/gateway/server-methods/update-run-campaign.test.ts index 0f55f154b280..1bce54b0718d 100644 --- a/src/gateway/server-methods/update-run-campaign.test.ts +++ b/src/gateway/server-methods/update-run-campaign.test.ts @@ -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>; +type UpdateCampaignAdoption = ReturnType; -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 { +async function invokeUpdateRun( + params: Record = {}, + respond: (ok: boolean, response?: unknown) => void = () => undefined, +): Promise { 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 { } as never); } +async function captureUpdateRun(params: Record) { + 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(); runGatewayUpdateMock.mockReturnValueOnce(deferredUpdate.promise); diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index e095ccac0667..6f6a0077c3b6 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -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 +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>(); 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", diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index ed10dc5253d5..7840cfd0db61 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -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 | 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, }); diff --git a/src/infra/update-campaign.test.ts b/src/infra/update-campaign.test.ts index 04a88287d37e..b821e145ecfe 100644 --- a/src/infra/update-campaign.test.ts +++ b/src/infra/update-campaign.test.ts @@ -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" }, }); diff --git a/src/infra/update-campaign.ts b/src/infra/update-campaign.ts index 73f4bc5d5948..e33e667f2dfc 100644 --- a/src/infra/update-campaign.ts +++ b/src/infra/update-campaign.ts @@ -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; type UpdateCampaignTarget = NonNullable; -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 { diff --git a/src/infra/update-dev-target.test.ts b/src/infra/update-dev-target.test.ts index 29c833d9a574..e046bf253b6d 100644 --- a/src/infra/update-dev-target.test.ts +++ b/src/infra/update-dev-target.test.ts @@ -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({ diff --git a/src/infra/update-dev-target.ts b/src/infra/update-dev-target.ts index 93ed3431ed63..9afa98892e36 100644 --- a/src/infra/update-dev-target.ts +++ b/src/infra/update-dev-target.ts @@ -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; -type GitUpdateCampaignTarget = Extract, { 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 { return { mode: "tracked", diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index c27def3f5bd4..12ce10758897 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -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, }),