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