feat(workspace): migrate legacy sisyphus state to omo

This commit is contained in:
YeonGyu-Kim
2026-05-16 17:39:54 +09:00
parent 76e573a920
commit 5dca1a5742
6 changed files with 184 additions and 0 deletions
+3
View File
@@ -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<typeof createPluginModule> {
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()
+22
View File
@@ -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<typeof createPluginModule> {
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<typeof pluginModule.server>[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
+1
View File
@@ -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"
@@ -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: [] })
})
})
+72
View File
@@ -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 }
}
}
+4
View File
@@ -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<PluginModuleDeps> = {}): P
directory: input.directory,
})
deps.logLegacyPluginStartupWarning()
deps.migrateLegacyWorkspaceDirectory(input.directory)
const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {