feat(workspace): migrate legacy sisyphus state to omo
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user