diff --git a/.github/workflows/packer-snapshots.yml b/.github/workflows/packer-snapshots.yml index 4d09c93b..21bf76e4 100644 --- a/.github/workflows/packer-snapshots.yml +++ b/.github/workflows/packer-snapshots.yml @@ -90,10 +90,12 @@ jobs: - name: Cleanup old snapshots if: success() run: | - # Keep only the latest snapshot per agent + # DO snapshots don't support tags — filter by name prefix instead + PREFIX="spawn-${AGENT_NAME}-" SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \ - "https://api.digitalocean.com/v2/images?private=true&per_page=100&tag_name=spawn-${AGENT_NAME}" \ - | jq -r '.images | sort_by(.created_at) | reverse | .[1:] | .[].id') + "https://api.digitalocean.com/v2/images?private=true&per_page=100" \ + | jq -r --arg prefix "$PREFIX" \ + '[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id') for ID in $SNAPSHOTS; do echo "Deleting old snapshot: ${ID}" diff --git a/packages/cli/package.json b/packages/cli/package.json index 92899344..227a4100 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.6", + "version": "0.15.7", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/do-snapshot.test.ts b/packages/cli/src/__tests__/do-snapshot.test.ts index 59ad1ad1..0935acae 100644 --- a/packages/cli/src/__tests__/do-snapshot.test.ts +++ b/packages/cli/src/__tests__/do-snapshot.test.ts @@ -2,7 +2,7 @@ * do-snapshot.test.ts — Tests for findSpawnSnapshot(). * * Verifies snapshot lookup: happy path, empty results, API errors, - * invalid IDs, and network failures all return correct values. + * invalid IDs, name filtering, and network failures. */ import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; @@ -37,14 +37,17 @@ describe("findSpawnSnapshot", () => { images: [ { id: 100, + name: "spawn-claude-20260101-0000", created_at: "2026-01-01T00:00:00Z", }, { id: 200, + name: "spawn-claude-20260301-0000", created_at: "2026-03-01T00:00:00Z", }, { id: 150, + name: "spawn-claude-20260201-0000", created_at: "2026-02-01T00:00:00Z", }, ], @@ -57,6 +60,32 @@ describe("findSpawnSnapshot", () => { expect(result).toBe("200"); }); + it("filters by name prefix — ignores other agents", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + images: [ + { + id: 300, + name: "spawn-codex-20260301-0000", + created_at: "2026-03-01T00:00:00Z", + }, + { + id: 400, + name: "spawn-claude-20260201-0000", + created_at: "2026-02-01T00:00:00Z", + }, + ], + }), + ), + ), + ); + + const result = await findSpawnSnapshot("claude"); + expect(result).toBe("400"); + }); + it("returns null when no images are found", async () => { globalThis.fetch = mock(() => Promise.resolve( @@ -72,6 +101,27 @@ describe("findSpawnSnapshot", () => { expect(result).toBeNull(); }); + it("returns null when no images match the agent name", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + images: [ + { + id: 100, + name: "spawn-codex-20260101-0000", + created_at: "2026-01-01T00:00:00Z", + }, + ], + }), + ), + ), + ); + + const result = await findSpawnSnapshot("claude"); + expect(result).toBeNull(); + }); + it("returns null on API error response", async () => { globalThis.fetch = mock(() => Promise.resolve( @@ -93,6 +143,7 @@ describe("findSpawnSnapshot", () => { images: [ { id: "not-a-number", + name: "spawn-claude-20260101-0000", created_at: "2026-01-01T00:00:00Z", }, ], diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index ebbdb357..ef1dc07d 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -887,14 +887,12 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis export async function findSpawnSnapshot(agentName: string): Promise { try { - const text = await doApi( - "GET", - `/images?private=true&per_page=50&tag_name=spawn-${encodeURIComponent(agentName)}`, - undefined, - 1, - ); + // DO snapshots don't support tags — filter by name prefix instead + const prefix = `spawn-${agentName}-`; + const text = await doApi("GET", "/images?private=true&per_page=100", undefined, 1); const data = parseJsonObj(text); - const images = toObjectArray(data?.images); + const allImages = toObjectArray(data?.images); + const images = allImages.filter((img) => isString(img.name) && img.name.startsWith(prefix)); if (images.length === 0) { return null; } diff --git a/packer/agents.json b/packer/agents.json index c0dd1748..e53f003e 100644 --- a/packer/agents.json +++ b/packer/agents.json @@ -2,7 +2,7 @@ "claude": { "tier": "minimal", "install": [ - "curl -fsSL https://claude.ai/install.sh | bash || mkdir -p ~/.npm-global/bin && npm install -g --prefix ~/.npm-global @anthropic-ai/claude-code" + "curl -fsSL https://claude.ai/install.sh | bash || [ -f /root/.local/bin/claude ]" ] }, "codex": { diff --git a/packer/digitalocean.pkr.hcl b/packer/digitalocean.pkr.hcl index 9a40cc75..d4ff9c76 100644 --- a/packer/digitalocean.pkr.hcl +++ b/packer/digitalocean.pkr.hcl @@ -45,8 +45,6 @@ source "digitalocean" "spawn" { "nyc1", "nyc3", "sfo3", "tor1", "ams3", "lon1", "fra1", "blr1", "sgp1", "syd1", ] - - tags = ["spawn", "spawn-${var.agent_name}"] } build {