Skip to content
Open
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
18 changes: 16 additions & 2 deletions lib/projects/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,23 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>

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:<id>").
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);
}

Expand Down Expand Up @@ -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;
}

/**
Expand Down
116 changes: 116 additions & 0 deletions lib/projects/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import path from "node:path";
import os from "node:os";
import {
readProjects,
getProject,
getRoleWorker,
emptyRoleWorkerState,
emptySlot,
Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions lib/projects/schema-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>" prefixed keys
return keys.length > 0 && keys.every(k => /^-?\d+$/.test(k) || /^channel:-?\d+$/.test(k));
}

/**
Expand Down Expand Up @@ -64,7 +65,9 @@ export async function migrateLegacySchema(data: any, runCommand?: RunCommand): P
const byName: Record<string, { channelIds: string[]; legacyProjects: LegacyProject[] }> = {};

// 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: [] };
}
Expand Down