From a0e1f1e467da24633393505396e289ffc28b0e20 Mon Sep 17 00:00:00 2001 From: "Philip (NUC)" Date: Sat, 28 Mar 2026 20:36:25 -0400 Subject: [PATCH] fix: inject project.slug from map key to prevent undefined slug in dispatch pipeline (#12) Root cause: getProject() returned raw JSON objects without the slug field populated. When projects.json used non-standard keys (e.g. channel:), project.slug was undefined, causing activateWorker() to fail with 'Project not found for slug or channelId: undefined'. This broke worker state recording after dispatch, leading to repeated dispatch attempts. Changes: - readProjects(): inject slug from map key into every project missing it - getProject(): defensively inject slug from resolved key - isLegacySchema(): detect channel:-prefixed keys as legacy format - migrateLegacySchema(): strip channel: prefix when extracting channelIds - Added tests for slug injection and legacy schema detection --- lib/projects/io.ts | 18 ++++- lib/projects/projects.test.ts | 116 +++++++++++++++++++++++++++++++ lib/projects/schema-migration.ts | 7 +- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/lib/projects/io.ts b/lib/projects/io.ts index bd11b27..7924e2b 100644 --- a/lib/projects/io.ts +++ b/lib/projects/io.ts @@ -79,12 +79,23 @@ export async function readProjects(workspaceDir: string): Promise const typedData = data as ProjectsData; + // Ensure every project has its slug field populated from the map key. + // Handles projects registered before slug was persisted or loaded from + // non-standard key formats (e.g. "channel:"). + let slugInjected = false; + for (const [key, project] of Object.entries(typedData.projects)) { + if (!project.slug) { + project.slug = key; + slugInjected = true; + } + } + // Apply per-project migrations and persist if any changed let migrated = false; for (const project of Object.values(typedData.projects)) { if (migrateProject(project as any)) migrated = true; } - if (migrated) { + if (migrated || slugInjected) { await writeProjects(workspaceDir, typedData); } @@ -132,7 +143,10 @@ export function getProject( slugOrChannelId: string, ): Project | undefined { const slug = resolveProjectSlug(data, slugOrChannelId); - return slug ? data.projects[slug] : undefined; + if (!slug) return undefined; + const project = data.projects[slug]; + if (project && !project.slug) project.slug = slug; + return project; } /** diff --git a/lib/projects/projects.test.ts b/lib/projects/projects.test.ts index bc8735e..cc52a60 100644 --- a/lib/projects/projects.test.ts +++ b/lib/projects/projects.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import os from "node:os"; import { readProjects, + getProject, getRoleWorker, emptyRoleWorkerState, emptySlot, @@ -432,3 +433,118 @@ describe("reconcileSlots", () => { assert.strictEqual(rw.levels.senior.length, 1); }); }); + +describe("slug injection", () => { + it("should inject slug from map key when project has no slug field", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-slug-")); + const dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); + + // Project without slug field (simulates pre-migration data) + const data = { + projects: { + "my-project": { + name: "my-project", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + channels: [{ channelId: "123", channel: "discord", name: "primary", events: ["*"] }], + workers: { developer: { levels: {} } }, + }, + }, + }; + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(data), "utf-8"); + + const loaded = await readProjects(tmpDir); + const project = loaded.projects["my-project"]; + assert.strictEqual(project.slug, "my-project", "slug should be injected from map key"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should not overwrite existing slug", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-slug-")); + const dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); + + const data = { + projects: { + "my-project": { + slug: "custom-slug", + name: "my-project", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + channels: [{ channelId: "123", channel: "discord", name: "primary", events: ["*"] }], + workers: { developer: { levels: {} } }, + }, + }, + }; + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(data), "utf-8"); + + const loaded = await readProjects(tmpDir); + const project = loaded.projects["my-project"]; + assert.strictEqual(project.slug, "custom-slug", "existing slug should not be overwritten"); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); + +describe("getProject slug injection", () => { + it("should inject slug via getProject even if readProjects was bypassed", () => { + // Simulate direct getProject call with data that has no slug + const data: ProjectsData = { + projects: { + "test-slug": { + name: "test", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + channels: [{ channelId: "456", channel: "telegram", name: "primary", events: ["*"] }], + workers: {}, + } as any, + }, + }; + const project = getProject(data, "test-slug"); + assert.ok(project); + assert.strictEqual(project.slug, "test-slug"); + }); +}); + +describe("isLegacySchema with channel: prefix", () => { + it("should detect channel:-prefixed keys as legacy", async () => { + const { isLegacySchema } = await import("./schema-migration.js"); + const data = { + projects: { + "channel:1483593453739839609": { + name: "workflow", + repo: "/home/test/Code/Workflow", + groupName: "#infra", + baseBranch: "main", + deployBranch: "main", + workers: { developer: { levels: {} } }, + channels: [{ channelId: "1483593453739839609", channel: "discord", name: "primary", events: ["*"] }], + }, + }, + }; + assert.strictEqual(isLegacySchema(data), true, "channel: prefixed keys should be detected as legacy"); + }); + + it("should detect purely numeric keys as legacy", async () => { + const { isLegacySchema } = await import("./schema-migration.js"); + const data = { projects: { "-1003844794417": { name: "test" } } }; + assert.strictEqual(isLegacySchema(data), true); + }); + + it("should not detect slug keys as legacy", async () => { + const { isLegacySchema } = await import("./schema-migration.js"); + const data = { projects: { "my-project": { name: "test" } } }; + assert.strictEqual(isLegacySchema(data), false); + }); +}); diff --git a/lib/projects/schema-migration.ts b/lib/projects/schema-migration.ts index a0c5771..54fabf4 100644 --- a/lib/projects/schema-migration.ts +++ b/lib/projects/schema-migration.ts @@ -28,7 +28,8 @@ function getFirstStartTime(worker: any): string | undefined { */ export function isLegacySchema(data: any): boolean { const keys = Object.keys(data.projects || {}); - return keys.length > 0 && keys.every(k => /^-?\d+$/.test(k)); + // Detect legacy formats: purely numeric channelIds OR "channel:" prefixed keys + return keys.length > 0 && keys.every(k => /^-?\d+$/.test(k) || /^channel:-?\d+$/.test(k)); } /** @@ -64,7 +65,9 @@ export async function migrateLegacySchema(data: any, runCommand?: RunCommand): P const byName: Record = {}; // Group by project name - for (const [channelId, legacyProj] of Object.entries(legacyProjects)) { + for (const [rawKey, legacyProj] of Object.entries(legacyProjects)) { + // Strip "channel:" prefix if present to get the actual channelId + const channelId = rawKey.startsWith("channel:") ? rawKey.slice(8) : rawKey; if (!byName[legacyProj.name]) { byName[legacyProj.name] = { channelIds: [], legacyProjects: [] }; }