Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 0 additions & 42 deletions .github/workflows/dev-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -638,44 +638,6 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Compute migration ceilings
id: migration-ceilings
run: |
DB_VERSION=$(node -e "
const fs = require('fs');
const content = fs.readFileSync('assistant/src/memory/migrations/registry.ts', 'utf-8');
const versions = [...content.matchAll(/version:\s*(\d+)/g)].map(m => parseInt(m[1]));
console.log(Math.max(...versions));
")
echo "db_version=$DB_VERSION" >> "$GITHUB_OUTPUT"

WS_ID=$(node -e "
const fs = require('fs');
const path = require('path');
const registry = fs.readFileSync('assistant/src/workspace/migrations/registry.ts', 'utf-8');
const importMap = {};
for (const m of registry.matchAll(/import\s+\{\s*(\w+)\s*\}\s+from\s+['\x22]\.\/([^'\x22]+)['\x22];/g)) {
importMap[m[1]] = m[2];
}
const arrayMatch = registry.match(/WORKSPACE_MIGRATIONS[^=]*=\s*\[([\s\S]*?)\]/);
const entries = arrayMatch[1].match(/\w+/g);
const lastVar = entries[entries.length - 1];
const relPath = importMap[lastVar];
const filePath = path.join('assistant/src/workspace/migrations', relPath.replace(/\.js$/, '.ts'));
const src = fs.readFileSync(filePath, 'utf-8');
let id;
const litMatch = src.match(/^\s*id:\s*['\x22]([^'\x22]+)['\x22]/m);
if (litMatch) {
id = litMatch[1];
} else {
const refMatch = src.match(/^\s*id:\s*(\w+)/m);
const constLine = src.split('\n').find(l => l.includes('const ' + refMatch[1] + ' ='));
id = constLine.match(/['\x22]([^'\x22]+)['\x22]/)[1];
}
console.log(id);
")
echo "ws_id=$WS_ID" >> "$GITHUB_OUTPUT"
echo "Migration ceilings: db=$DB_VERSION, workspace=$WS_ID"

- name: Download manifest digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
Expand All @@ -702,8 +664,6 @@ jobs:
--arg version "$VERSION" \
--arg released_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson is_stable true \
--argjson db_migration_version "${{ steps.migration-ceilings.outputs.db_version }}" \
--arg last_workspace_migration_id "${{ steps.migration-ceilings.outputs.ws_id }}" \
--arg assistant_repo "$ASSISTANT_REPO" \
--arg assistant_tag "${VERSION}" \
--arg assistant_sha "$SHA" \
Expand All @@ -720,8 +680,6 @@ jobs:
version: $version,
released_at: $released_at,
is_stable: $is_stable,
db_migration_version: $db_migration_version,
last_workspace_migration_id: $last_workspace_migration_id,
assistant_image: {
repository: $assistant_repo,
tag: $assistant_tag,
Expand Down
44 changes: 0 additions & 44 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1085,46 +1085,6 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}

- name: Compute migration ceilings
id: migration-ceilings
run: |
# Extract max DB migration version from the registry
DB_VERSION=$(node -e "
const fs = require('fs');
const content = fs.readFileSync('assistant/src/memory/migrations/registry.ts', 'utf-8');
const versions = [...content.matchAll(/version:\s*(\d+)/g)].map(m => parseInt(m[1]));
console.log(Math.max(...versions));
")
echo "db_version=$DB_VERSION" >> "$GITHUB_OUTPUT"

# Extract last workspace migration ID from the registry
WS_ID=$(node -e "
const fs = require('fs');
const path = require('path');
const registry = fs.readFileSync('assistant/src/workspace/migrations/registry.ts', 'utf-8');
const importMap = {};
for (const m of registry.matchAll(/import\s+\{\s*(\w+)\s*\}\s+from\s+['\x22]\.\/([^'\x22]+)['\x22];/g)) {
importMap[m[1]] = m[2];
}
const arrayMatch = registry.match(/WORKSPACE_MIGRATIONS[^=]*=\s*\[([\s\S]*?)\]/);
const entries = arrayMatch[1].match(/\w+/g);
const lastVar = entries[entries.length - 1];
const relPath = importMap[lastVar];
const filePath = path.join('assistant/src/workspace/migrations', relPath.replace(/\.js$/, '.ts'));
const src = fs.readFileSync(filePath, 'utf-8');
let id;
const litMatch = src.match(/^\s*id:\s*['\x22]([^'\x22]+)['\x22]/m);
if (litMatch) {
id = litMatch[1];
} else {
const refMatch = src.match(/^\s*id:\s*(\w+)/m);
const constLine = src.split('\n').find(l => l.includes('const ' + refMatch[1] + ' ='));
id = constLine.match(/['\x22]([^'\x22]+)['\x22]/)[1];
}
console.log(id);
")
echo "ws_id=$WS_ID" >> "$GITHUB_OUTPUT"
echo "Migration ceilings: db=$DB_VERSION, workspace=$WS_ID"

- name: Get release SHA
id: release-sha
Expand Down Expand Up @@ -1177,8 +1137,6 @@ jobs:
--arg version "$VERSION" \
--arg released_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson is_stable "$IS_STABLE" \
--argjson db_migration_version "${{ steps.migration-ceilings.outputs.db_version }}" \
--arg last_workspace_migration_id "${{ steps.migration-ceilings.outputs.ws_id }}" \
--arg assistant_repo "$ASSISTANT_REPO" \
--arg assistant_tag "v${VERSION}" \
--arg assistant_sha "$SHA" \
Expand All @@ -1195,8 +1153,6 @@ jobs:
version: $version,
released_at: $released_at,
is_stable: $is_stable,
db_migration_version: $db_migration_version,
last_workspace_migration_id: $last_workspace_migration_id,
assistant_image: {
repository: $assistant_repo,
tag: $assistant_tag,
Expand Down
82 changes: 9 additions & 73 deletions cli/src/lib/upgrade-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
import { getStateDir } from "./environments/paths.js";
import { getCurrentEnvironment } from "./environments/resolve.js";
import { loadGuardianToken } from "./guardian-token.js";
import { getPlatformUrl } from "./platform-client.js";
import { resolveImageRefs } from "./platform-releases.js";
import { exec, execOutput } from "./step-runner.js";
import { compareVersions } from "./version-compat.js";
Expand Down Expand Up @@ -481,42 +480,6 @@ export async function performDockerRollback(
console.log("🔍 Resolving image references...");
const { imageTags: targetImageTags } = await resolveImageRefs(targetVersion);

// Fetch target migration ceiling from releases API
let targetMigrationCeiling: {
dbVersion?: number;
workspaceMigrationId?: string;
} = {};
try {
const platformUrl = getPlatformUrl();
const releasesResp = await fetch(
`${platformUrl}/v1/releases/?stable=true`,
{ signal: AbortSignal.timeout(10000) },
);
if (releasesResp.ok) {
const releases = (await releasesResp.json()) as Array<{
version: string;
db_migration_version?: number | null;
last_workspace_migration_id?: string;
}>;
const normalizedTag = targetVersion.replace(/^v/, "");
const targetRelease = releases.find(
(r) => r.version?.replace(/^v/, "") === normalizedTag,
);
if (
targetRelease?.db_migration_version != null ||
targetRelease?.last_workspace_migration_id
) {
targetMigrationCeiling = {
dbVersion: targetRelease.db_migration_version ?? undefined,
workspaceMigrationId:
targetRelease.last_workspace_migration_id || undefined,
};
}
}
} catch {
// Best-effort — fall back to rollbackToRegistryCeiling post-swap
}

// Capture current image digests for auto-rollback on failure
console.log("📸 Capturing current image references for rollback...");
const currentImageRefs = await captureImageRefs(res);
Expand Down Expand Up @@ -702,26 +665,6 @@ export async function performDockerRollback(
}
console.log("✅ Docker images pulled\n");

// Pre-swap migration rollback to target ceiling on the CURRENT (newer) daemon
let preSwapRollbackOk = true;
if (
targetMigrationCeiling.dbVersion !== undefined ||
targetMigrationCeiling.workspaceMigrationId !== undefined
) {
console.log("🔄 Reverting database changes...");
await broadcastUpgradeEvent(
entry.runtimeUrl,
entry.assistantId,
buildProgressEvent(UPGRADE_PROGRESS.REVERTING_MIGRATIONS),
);
preSwapRollbackOk = await rollbackMigrations(
entry.runtimeUrl,
entry.assistantId,
targetMigrationCeiling.dbVersion,
targetMigrationCeiling.workspaceMigrationId,
);
}

// Progress: switching version
await broadcastUpgradeEvent(
entry.runtimeUrl,
Expand Down Expand Up @@ -757,22 +700,15 @@ export async function performDockerRollback(
if (ready) {
// Success path

Comment on lines 700 to 702
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back migrations before enforcing readiness

This change makes migration rollback happen only after waitForReady() succeeds, so any target image that cannot boot against the newer DB/workspace schema will never get a chance to downgrade its migrations and the rollback will always fail back to the current version. In performDockerRollback, rollbackMigrations(..., rollbackToRegistryCeiling: true) now runs only inside the success branch, which removes the previous pre-swap safety path and breaks rollbacks to versions that require schema rollback before startup.

Useful? React with 👍 / 👎.

// Post-swap migration rollback fallback: if pre-swap rollback failed
// or no ceiling metadata was available, ask the now-running old daemon
// to roll back migrations above its own registry ceiling.
if (
!preSwapRollbackOk ||
(targetMigrationCeiling.dbVersion === undefined &&
targetMigrationCeiling.workspaceMigrationId === undefined)
) {
await rollbackMigrations(
entry.runtimeUrl,
entry.assistantId,
undefined,
undefined,
true,
);
}
// Post-swap migration rollback: ask the now-running old daemon to roll
// back any migrations above its own registry ceiling.
await rollbackMigrations(
entry.runtimeUrl,
entry.assistantId,
undefined,
undefined,
true,
);
Comment on lines +703 to +711
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Semantic change: migration rollback now always deferred to post-swap daemon

Previously, performDockerRollback attempted to roll back migrations on the CURRENT (newer) daemon before swapping containers, using specific target versions from the releases API. Only if that failed would it fall back to post-swap rollbackToRegistryCeiling on the TARGET (older) daemon. Now, migration rollback always happens post-swap via rollbackToRegistryCeiling (lines 706-712). This means the older daemon is now solely responsible for rolling back migrations introduced by the newer version. This works because rollbackToRegistryCeiling tells the daemon to revert anything above its own known migration set — the daemon's rollback handler has the necessary down() logic for migrations in its registry. This is a valid simplification, but it does mean that if a newer version introduced migrations whose rollback logic isn't present in the older version's codebase, those migrations won't be rolled back. This appears to be an accepted trade-off based on the PR's design.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Capture new digests from the rolled-back containers
const newDigests = await captureImageRefs(res);
Expand Down