From 5dca1a5742de4153e3980addeb44653069ad5b39 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 17:39:54 +0900 Subject: [PATCH] feat(workspace): migrate legacy sisyphus state to omo --- src/index.telemetry.test.ts | 3 + src/index.test.ts | 22 +++++ src/shared/index.ts | 1 + src/shared/legacy-workspace-migration.test.ts | 82 +++++++++++++++++++ src/shared/legacy-workspace-migration.ts | 72 ++++++++++++++++ src/testing/create-plugin-module.ts | 4 + 6 files changed, 184 insertions(+) create mode 100644 src/shared/legacy-workspace-migration.test.ts create mode 100644 src/shared/legacy-workspace-migration.ts diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts index 5d736b164..e799b8bf4 100644 --- a/src/index.telemetry.test.ts +++ b/src/index.telemetry.test.ts @@ -4,6 +4,7 @@ import { createPluginModule } from "./testing/create-plugin-module" const mockInitConfigContext = mock(() => {}) const mockInjectServerAuthIntoClient = mock(() => {}) const mockLogLegacyPluginStartupWarning = mock(() => {}) +const mockMigrateLegacyWorkspaceDirectory = mock(() => ({ migrated: false, skipped: [] })) const mockLoadPluginConfig = mock(() => ({})) const mockIsTmuxIntegrationEnabled = mock(() => false) const mockCreateRuntimeTmuxConfig = mock(() => ({ @@ -38,6 +39,7 @@ function createTestPluginModule(): ReturnType { initConfigContext: mockInitConfigContext, injectServerAuthIntoClient: mockInjectServerAuthIntoClient, logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, + migrateLegacyWorkspaceDirectory: mockMigrateLegacyWorkspaceDirectory, loadPluginConfig: mockLoadPluginConfig as never, isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never, createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never, @@ -67,6 +69,7 @@ describe("oh-my-openagent telemetry isolation", () => { mockInitConfigContext.mockClear() mockInjectServerAuthIntoClient.mockClear() mockLogLegacyPluginStartupWarning.mockClear() + mockMigrateLegacyWorkspaceDirectory.mockClear() mockLoadPluginConfig.mockClear() mockIsTmuxIntegrationEnabled.mockClear() mockCreateRuntimeTmuxConfig.mockClear() diff --git a/src/index.test.ts b/src/index.test.ts index 8089321cc..842164c17 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -6,6 +6,7 @@ const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: const mockGetSkillPluginConflictWarning = mock(() => "") const mockInjectServerAuthIntoClient = mock(() => {}) const mockLogLegacyPluginStartupWarning = mock(() => {}) +const mockMigrateLegacyWorkspaceDirectory = mock(() => ({ migrated: false, skipped: [] })) const mockLoadPluginConfig = mock(() => ({})) const mockIsTmuxIntegrationEnabled = mock( (pluginConfig: { tmux?: { enabled?: boolean } | undefined }) => pluginConfig.tmux?.enabled ?? false, @@ -57,6 +58,7 @@ function createTestPluginModule(): ReturnType { getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning, injectServerAuthIntoClient: mockInjectServerAuthIntoClient, logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, + migrateLegacyWorkspaceDirectory: mockMigrateLegacyWorkspaceDirectory, loadPluginConfig: mockLoadPluginConfig as never, isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never, createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never, @@ -81,6 +83,7 @@ describe("oh-my-openagent plugin module", () => { mockGetSkillPluginConflictWarning.mockClear() mockInjectServerAuthIntoClient.mockClear() mockLogLegacyPluginStartupWarning.mockClear() + mockMigrateLegacyWorkspaceDirectory.mockClear() mockLoadPluginConfig.mockClear() mockIsTmuxIntegrationEnabled.mockClear() mockCreateRuntimeTmuxConfig.mockClear() @@ -134,6 +137,25 @@ describe("oh-my-openagent plugin module", () => { expect(mockInitializeOpenClaw).not.toHaveBeenCalled() }, { timeout: 15000 }) + it("migrates legacy workspace state during plugin bootstrap", async () => { + // given + const directory = "/tmp/project" + mockLoadPluginConfig.mockReturnValue({}) + + // when + await pluginModule.server({ + directory, + client: {}, + } as Parameters[0]) + + // then + expect(mockMigrateLegacyWorkspaceDirectory).toHaveBeenCalledTimes(1) + expect(mockMigrateLegacyWorkspaceDirectory).toHaveBeenCalledWith(directory) + expect(mockMigrateLegacyWorkspaceDirectory.mock.invocationCallOrder[0]).toBeLessThan( + mockLoadPluginConfig.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ) + }) + it("exports a V1 PluginModule shape with id and server", () => { // given the plugin module is loaded // when inspecting the default export diff --git a/src/shared/index.ts b/src/shared/index.ts index 9b29c6fbe..07103d094 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -79,6 +79,7 @@ export * from "./plugin-command-discovery" export { SessionCategoryRegistry } from "./session-category-registry" export * from "./plugin-identity" export * from "./log-legacy-plugin-startup-warning" +export * from "./legacy-workspace-migration" export * from "./task-system-enabled" export * from "./parse-tools-config" export { parseModelString } from "./model-string-parser" diff --git a/src/shared/legacy-workspace-migration.test.ts b/src/shared/legacy-workspace-migration.test.ts new file mode 100644 index 000000000..2651de6c8 --- /dev/null +++ b/src/shared/legacy-workspace-migration.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { migrateLegacyWorkspaceDirectory } from "./legacy-workspace-migration" + +describe("migrateLegacyWorkspaceDirectory", () => { + let testDirectory = "" + + beforeEach(() => { + testDirectory = join(tmpdir(), `omo-workspace-migration-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDirectory, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDirectory, { recursive: true, force: true }) + }) + + test("#given legacy workspace with nested state and no target #when migrating #then copies the tree to .omo", () => { + // given + const legacyPlanPath = join(testDirectory, ".sisyphus", "plans", "work.md") + const legacyNotepadDirectory = join(testDirectory, ".sisyphus", "notepads", "work") + const legacyNotepadPath = join(legacyNotepadDirectory, "notes.md") + mkdirSync(legacyNotepadDirectory, { recursive: true }) + mkdirSync(join(testDirectory, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(legacyPlanPath, "# Plan", "utf-8") + writeFileSync(legacyNotepadPath, "note", "utf-8") + + // when + const result = migrateLegacyWorkspaceDirectory(testDirectory) + + // then + expect(result.migrated).toBe(true) + expect(readFileSync(join(testDirectory, ".omo", "plans", "work.md"), "utf-8")).toBe("# Plan") + expect(readFileSync(join(testDirectory, ".omo", "notepads", "work", "notes.md"), "utf-8")).toBe("note") + expect(existsSync(join(testDirectory, ".sisyphus", "plans", "work.md"))).toBe(true) + }) + + test("#given target file already exists #when migrating #then keeps the target content", () => { + // given + const legacyPlanPath = join(testDirectory, ".sisyphus", "plans", "work.md") + const targetPlanPath = join(testDirectory, ".omo", "plans", "work.md") + mkdirSync(join(testDirectory, ".sisyphus", "plans"), { recursive: true }) + mkdirSync(join(testDirectory, ".omo", "plans"), { recursive: true }) + writeFileSync(legacyPlanPath, "legacy", "utf-8") + writeFileSync(targetPlanPath, "target", "utf-8") + + // when + const result = migrateLegacyWorkspaceDirectory(testDirectory) + + // then + expect(result.migrated).toBe(false) + expect(result.skipped).toContain(join(".omo", "plans", "work.md")) + expect(readFileSync(targetPlanPath, "utf-8")).toBe("target") + }) + + test("#given target has other files #when migrating #then copies only missing legacy files", () => { + // given + const legacyPlanPath = join(testDirectory, ".sisyphus", "plans", "work.md") + const targetNotepadPath = join(testDirectory, ".omo", "notepads", "work", "notes.md") + mkdirSync(join(testDirectory, ".sisyphus", "plans"), { recursive: true }) + mkdirSync(join(testDirectory, ".omo", "notepads", "work"), { recursive: true }) + writeFileSync(legacyPlanPath, "legacy plan", "utf-8") + writeFileSync(targetNotepadPath, "existing note", "utf-8") + + // when + const result = migrateLegacyWorkspaceDirectory(testDirectory) + + // then + expect(result.migrated).toBe(true) + expect(readFileSync(join(testDirectory, ".omo", "plans", "work.md"), "utf-8")).toBe("legacy plan") + expect(readFileSync(targetNotepadPath, "utf-8")).toBe("existing note") + }) + + test("#given no legacy workspace #when migrating #then reports no migration", () => { + // when + const result = migrateLegacyWorkspaceDirectory(testDirectory) + + // then + expect(result).toEqual({ migrated: false, skipped: [] }) + }) +}) diff --git a/src/shared/legacy-workspace-migration.ts b/src/shared/legacy-workspace-migration.ts new file mode 100644 index 000000000..5dac990bb --- /dev/null +++ b/src/shared/legacy-workspace-migration.ts @@ -0,0 +1,72 @@ +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs" +import { dirname, join, relative } from "node:path" + +import { log } from "./logger" + +const LEGACY_WORKSPACE_DIR = ".sisyphus" +const WORKSPACE_DIR = ".omo" + +export type LegacyWorkspaceMigrationResult = { + migrated: boolean + skipped: string[] +} + +function copyMissingEntries(legacyPath: string, targetPath: string, targetRoot: string, skipped: string[]): boolean { + const legacyStat = statSync(legacyPath) + + if (existsSync(targetPath)) { + if (legacyStat.isDirectory() && statSync(targetPath).isDirectory()) { + let copiedChild = false + for (const entry of readdirSync(legacyPath)) { + copiedChild = copyMissingEntries(join(legacyPath, entry), join(targetPath, entry), targetRoot, skipped) || copiedChild + } + return copiedChild + } + + skipped.push(join(WORKSPACE_DIR, relative(targetRoot, targetPath))) + return false + } + + if (legacyStat.isDirectory()) { + mkdirSync(targetPath, { recursive: true }) + let copiedChild = false + for (const entry of readdirSync(legacyPath)) { + copiedChild = copyMissingEntries(join(legacyPath, entry), join(targetPath, entry), targetRoot, skipped) || copiedChild + } + return copiedChild + } + + mkdirSync(dirname(targetPath), { recursive: true }) + copyFileSync(legacyPath, targetPath) + return true +} + +export function migrateLegacyWorkspaceDirectory(directory: string): LegacyWorkspaceMigrationResult { + const legacyDirectory = join(directory, LEGACY_WORKSPACE_DIR) + if (!existsSync(legacyDirectory)) { + return { migrated: false, skipped: [] } + } + + const targetDirectory = join(directory, WORKSPACE_DIR) + const skipped: string[] = [] + + try { + const migrated = copyMissingEntries(legacyDirectory, targetDirectory, targetDirectory, skipped) + if (migrated || skipped.length > 0) { + log("[legacy-workspace-migration] Checked legacy workspace directory", { + legacyDirectory, + targetDirectory, + migrated, + skipped, + }) + } + return { migrated, skipped } + } catch (error) { + log("[legacy-workspace-migration] Failed to migrate legacy workspace directory", { + legacyDirectory, + targetDirectory, + error, + }) + return { migrated: false, skipped } + } +} diff --git a/src/testing/create-plugin-module.ts b/src/testing/create-plugin-module.ts index 36029d2fa..684890976 100644 --- a/src/testing/create-plugin-module.ts +++ b/src/testing/create-plugin-module.ts @@ -20,6 +20,7 @@ import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "../sha import { createFirstMessageVariantGate } from "../shared/first-message-variant" import { log } from "../shared/logger" import { logLegacyPluginStartupWarning } from "../shared/log-legacy-plugin-startup-warning" +import { migrateLegacyWorkspaceDirectory } from "../shared/legacy-workspace-migration" import { injectServerAuthIntoClient } from "../shared/opencode-server-auth" import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash" @@ -33,6 +34,7 @@ export type PluginModuleDeps = { setAgentSortOrder: typeof setAgentSortOrder log: typeof log logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning + migrateLegacyWorkspaceDirectory: typeof migrateLegacyWorkspaceDirectory detectExternalSkillPlugin: typeof detectExternalSkillPlugin getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning injectServerAuthIntoClient: typeof injectServerAuthIntoClient @@ -55,6 +57,7 @@ const defaultPluginModuleDeps: PluginModuleDeps = { setAgentSortOrder, log, logLegacyPluginStartupWarning, + migrateLegacyWorkspaceDirectory, detectExternalSkillPlugin, getSkillPluginConflictWarning, injectServerAuthIntoClient, @@ -80,6 +83,7 @@ export function createPluginModule(overrides: Partial = {}): P directory: input.directory, }) deps.logLegacyPluginStartupWarning() + deps.migrateLegacyWorkspaceDirectory(input.directory) const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory) if (skillPluginCheck.detected && skillPluginCheck.pluginName) {