mirror of
https://github.com/openai/codex.git
synced 2026-04-23 22:24:57 +00:00
Compare commits
1 Commits
pr9012
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a5085718 |
@@ -80,6 +80,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored
|
||||
- [Example prompts](./docs/getting-started.md#example-prompts)
|
||||
- [Custom prompts](./docs/prompts.md)
|
||||
- [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)
|
||||
- [**Migrations**](./docs/migrations.md)
|
||||
- [**Configuration**](./docs/config.md)
|
||||
- [Example config](./docs/example-config.md)
|
||||
- [**Sandbox & approvals**](./docs/sandbox.md)
|
||||
|
||||
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -967,6 +967,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"assert_matches",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-app-server",
|
||||
@@ -989,8 +990,10 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"ctor 0.5.0",
|
||||
"owo-colors",
|
||||
"pathdiff",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
clap_complete = { workspace = true }
|
||||
codex-app-server = { workspace = true }
|
||||
@@ -37,6 +38,8 @@ codex-stdio-to-uds = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
@@ -26,8 +26,10 @@ use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod mcp_cmd;
|
||||
mod migrate;
|
||||
|
||||
use crate::mcp_cmd::McpCli;
|
||||
use crate::migrate::MigrateCli;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::features::is_known_feature_key;
|
||||
@@ -73,6 +75,9 @@ enum Subcommand {
|
||||
/// Remove stored authentication credentials.
|
||||
Logout(LogoutCommand),
|
||||
|
||||
/// Manage Codex migration workstreams.
|
||||
Migrate(MigrateCli),
|
||||
|
||||
/// [experimental] Run Codex as an MCP server and manage MCP servers.
|
||||
Mcp(McpCli),
|
||||
|
||||
@@ -443,6 +448,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
);
|
||||
run_logout(logout_cli.config_overrides).await;
|
||||
}
|
||||
Some(Subcommand::Migrate(migrate_cli)) => {
|
||||
migrate_cli.run()?;
|
||||
}
|
||||
Some(Subcommand::Completion(completion_cli)) => {
|
||||
print_completion(completion_cli);
|
||||
}
|
||||
|
||||
964
codex-rs/cli/src/migrate.rs
Normal file
964
codex-rs/cli/src/migrate.rs
Normal file
@@ -0,0 +1,964 @@
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use clap::Subcommand;
|
||||
use clap::ValueEnum;
|
||||
use codex_tui::migration::MigrationWorkspace;
|
||||
use codex_tui::migration::build_migration_prompt;
|
||||
use codex_tui::migration::create_migration_workspace;
|
||||
use pathdiff::diff_paths;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
const PACKAGE_NAME: &str = "@codex/migrate";
|
||||
const PACKAGE_DIR: &str = ".codex/migrate";
|
||||
const MANIFEST_FILE: &str = "manifest.toml";
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
const MIGRATIONS_DIR: &str = "migrations";
|
||||
const UI_DIR: &str = "migration-ui";
|
||||
const TASKS_FILE: &str = "tasks.json";
|
||||
const RUNS_DIR: &str = "runs";
|
||||
const STATE_VERSION: u32 = 1;
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub(crate) struct MigrateCli {
|
||||
/// Root of the repository / workspace that owns the migration artifacts.
|
||||
#[arg(long = "root", value_name = "DIR", default_value = ".")]
|
||||
root: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: MigrateCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum MigrateCommand {
|
||||
/// Install or refresh the @codex/migrate package manifest.
|
||||
Setup(SetupCommand),
|
||||
/// Create a migration workspace and seed a task graph.
|
||||
Plan(PlanCommand),
|
||||
/// Execute or update a migration task.
|
||||
Execute(ExecuteCommand),
|
||||
/// Generate helper UI assets.
|
||||
#[command(subcommand)]
|
||||
Ui(UiCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SetupCommand {
|
||||
/// Overwrite the existing manifest and index if present.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Name of a connector or data system to include in the manifest (repeatable).
|
||||
#[arg(long = "connector", value_name = "NAME", action = clap::ArgAction::Append)]
|
||||
connectors: Vec<String>,
|
||||
|
||||
/// Name of an MCP server required for the migration (repeatable).
|
||||
#[arg(long = "mcp", value_name = "NAME", action = clap::ArgAction::Append)]
|
||||
mcps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct PlanCommand {
|
||||
/// Short description for the migration (used to name the workspace).
|
||||
#[arg(value_name = "DESCRIPTION")]
|
||||
summary: String,
|
||||
|
||||
/// How many explorer workstreams should be created for parallel agents.
|
||||
#[arg(long = "parallel", value_name = "COUNT", default_value_t = 2)]
|
||||
parallel_scouts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ExecuteCommand {
|
||||
/// Specific task id to update. Omit to pick the next runnable task.
|
||||
#[arg(value_name = "TASK_ID")]
|
||||
task_id: Option<String>,
|
||||
|
||||
/// Name (or path) of the migration workspace to operate on.
|
||||
#[arg(long = "workspace", value_name = "PATH")]
|
||||
workspace: Option<String>,
|
||||
|
||||
/// Explicitly set a task's status instead of starting it.
|
||||
#[arg(long = "status", value_enum, requires = "task_id")]
|
||||
status: Option<TaskStatus>,
|
||||
|
||||
/// Append a short note to journal.md after updating the task.
|
||||
#[arg(long = "note", value_name = "TEXT")]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum UiCommand {
|
||||
/// Scaffold a dashboard that reads .codex/migrate/index.json.
|
||||
Init(UiInitCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct UiInitCommand {
|
||||
/// Overwrite the existing UI scaffold if it already exists.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
impl MigrateCli {
|
||||
pub(crate) fn run(self) -> Result<()> {
|
||||
let root = self
|
||||
.root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| self.root.clone());
|
||||
match self.command {
|
||||
MigrateCommand::Setup(cmd) => run_setup(&root, cmd),
|
||||
MigrateCommand::Plan(cmd) => run_plan(&root, cmd),
|
||||
MigrateCommand::Execute(cmd) => run_execute(&root, cmd),
|
||||
MigrateCommand::Ui(ui_cmd) => match ui_cmd {
|
||||
UiCommand::Init(cmd) => run_ui_init(&root, cmd),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_setup(root: &Path, cmd: SetupCommand) -> Result<()> {
|
||||
fs::create_dir_all(package_dir(root))?;
|
||||
let manifest_path = manifest_path(root);
|
||||
if manifest_path.exists() && !cmd.force {
|
||||
anyhow::bail!(
|
||||
"Migration manifest already exists at {} (use --force to overwrite)",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
let manifest = MigrationManifest::new(cmd.connectors, cmd.mcps);
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
write_package_readme(root)?;
|
||||
let index_path = index_path(root);
|
||||
if cmd.force || !index_path.exists() {
|
||||
let index = MigrationIndex::default();
|
||||
write_pretty_json(&index_path, &index)?;
|
||||
}
|
||||
println!(
|
||||
"Installed {PACKAGE_NAME} manifest at {}",
|
||||
manifest_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_plan(root: &Path, cmd: PlanCommand) -> Result<()> {
|
||||
let mut manifest = load_manifest(root)?;
|
||||
let migrations_dir = root.join(MIGRATIONS_DIR);
|
||||
let workspace = create_migration_workspace(&migrations_dir, cmd.summary.as_str())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create migration workspace inside {}",
|
||||
migrations_dir.display()
|
||||
)
|
||||
})?;
|
||||
let connectors = manifest.connectors.clone();
|
||||
let parallel = cmd.parallel_scouts.clamp(1, 8);
|
||||
let state = MigrationState::new(cmd.summary.clone(), &workspace, connectors, parallel);
|
||||
state.save()?;
|
||||
write_workspace_prompt(root, &workspace, cmd.summary.as_str())?;
|
||||
write_workspace_readme(&workspace, cmd.summary.as_str())?;
|
||||
let workspace_rel = diff_paths(&workspace.dir_path, root)
|
||||
.unwrap_or_else(|| workspace.dir_path.clone())
|
||||
.display()
|
||||
.to_string();
|
||||
manifest.last_workspace = Some(workspace_rel.clone());
|
||||
write_manifest(&manifest_path(root), &manifest)?;
|
||||
refresh_index(root, &state)?;
|
||||
println!(
|
||||
"Created migration workspace `{}` in {workspace_rel}",
|
||||
workspace.dir_name
|
||||
);
|
||||
println!("- Plan: {}", rel_to_root(&workspace.plan_path, root));
|
||||
println!("- Journal: {}", rel_to_root(&workspace.journal_path, root));
|
||||
println!(
|
||||
"Next: `codex migrate execute --workspace {}` to start assigning tasks or open the workspace and run /migrate",
|
||||
workspace.dir_name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_execute(root: &Path, cmd: ExecuteCommand) -> Result<()> {
|
||||
let workspace_dir = resolve_workspace(root, cmd.workspace.as_deref())?;
|
||||
let mut state = MigrationState::load(workspace_dir)?;
|
||||
let task_id = if let Some(id) = cmd.task_id {
|
||||
id
|
||||
} else if let Some(id) = state.next_runnable_task_id() {
|
||||
id
|
||||
} else {
|
||||
println!("All tasks are complete. Specify --task-id to override.");
|
||||
return Ok(());
|
||||
};
|
||||
if !state.can_start(&task_id) && cmd.status.is_none() {
|
||||
anyhow::bail!(
|
||||
"Task `{task_id}` is blocked by its dependencies. Complete the prerequisites or pass --status to override."
|
||||
);
|
||||
}
|
||||
let new_status = cmd.status.unwrap_or(TaskStatus::Running);
|
||||
state.set_status(&task_id, new_status)?;
|
||||
let mut run_file = None;
|
||||
if new_status == TaskStatus::Running && cmd.status.is_none() {
|
||||
run_file = Some(write_run_file(root, &state, &task_id)?);
|
||||
}
|
||||
state.save()?;
|
||||
if let Some(note) = cmd.note {
|
||||
append_journal(&state, &task_id, new_status, note.as_str())?;
|
||||
}
|
||||
refresh_index(root, &state)?;
|
||||
println!("Task `{task_id}` status -> {new_status}");
|
||||
if let Some(path) = run_file {
|
||||
println!("Runbook prepared at {path}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_ui_init(root: &Path, cmd: UiInitCommand) -> Result<()> {
|
||||
let ui_dir = root.join(UI_DIR);
|
||||
if ui_dir.exists() {
|
||||
if !cmd.force {
|
||||
anyhow::bail!(
|
||||
"UI scaffold already exists at {} (use --force to overwrite)",
|
||||
ui_dir.display()
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(&ui_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to remove existing UI directory {}",
|
||||
ui_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&ui_dir)?;
|
||||
fs::write(ui_dir.join("README.md"), ui_readme())?;
|
||||
fs::write(ui_dir.join("index.html"), ui_index_html())?;
|
||||
fs::write(ui_dir.join("styles.css"), ui_styles())?;
|
||||
fs::write(ui_dir.join("app.js"), ui_script())?;
|
||||
println!(
|
||||
"Migration dashboard scaffolded at {}. Serve the repo root and open /{UI_DIR}/",
|
||||
ui_dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_workspace(root: &Path, provided: Option<&str>) -> Result<PathBuf> {
|
||||
if let Some(input) = provided {
|
||||
let direct = PathBuf::from(input);
|
||||
let candidate = if direct.is_absolute() {
|
||||
direct
|
||||
} else {
|
||||
let joined = root.join(&direct);
|
||||
if joined.join(TASKS_FILE).exists() {
|
||||
joined
|
||||
} else {
|
||||
root.join(MIGRATIONS_DIR).join(&direct)
|
||||
}
|
||||
};
|
||||
if candidate.join(TASKS_FILE).exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
anyhow::bail!("No migration workspace found at {}", candidate.display());
|
||||
}
|
||||
let index = load_index(&index_path(root))?;
|
||||
let latest = index
|
||||
.migrations
|
||||
.iter()
|
||||
.max_by_key(|entry| entry.updated_at_epoch)
|
||||
.context("No recorded migrations. Run `codex migrate plan` first.")?;
|
||||
let rel = PathBuf::from(&latest.workspace);
|
||||
let path = if rel.is_absolute() {
|
||||
rel
|
||||
} else {
|
||||
root.join(rel)
|
||||
};
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_workspace_prompt(
|
||||
root: &Path,
|
||||
workspace: &MigrationWorkspace,
|
||||
summary: &str,
|
||||
) -> Result<()> {
|
||||
let workspace_display = rel_to_root(&workspace.dir_path, root);
|
||||
let plan_display = rel_to_root(&workspace.plan_path, root);
|
||||
let journal_display = rel_to_root(&workspace.journal_path, root);
|
||||
let prompt =
|
||||
build_migration_prompt(summary, &workspace_display, &plan_display, &journal_display);
|
||||
fs::write(workspace.dir_path.join("prompt.txt"), prompt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_workspace_readme(workspace: &MigrationWorkspace, summary: &str) -> Result<()> {
|
||||
let contents = format!(
|
||||
"# {}\n\n{}\n\n- `plan.md` – canonical blueprint\n- `journal.md` – publish progress + hand-offs\n- `tasks.json` – orchestration metadata\n- `runs/` – generated runbooks per task\n\nUse `codex migrate execute --workspace {}` to advance tasks or open this folder in Codex and run `/migrate`.\n",
|
||||
workspace.dir_name, summary, workspace.dir_name
|
||||
);
|
||||
fs::write(workspace.dir_path.join("README.md"), contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_journal(
|
||||
state: &MigrationState,
|
||||
task_id: &str,
|
||||
status: TaskStatus,
|
||||
note: &str,
|
||||
) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(state.journal_path())
|
||||
.with_context(|| format!("failed to open {}", state.journal_path().display()))?;
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M %Z");
|
||||
writeln!(
|
||||
file,
|
||||
"| {timestamp} | migrate::execute | Task {task_id} -> {status} | | {note} |"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_run_file(root: &Path, state: &MigrationState, task_id: &str) -> Result<String> {
|
||||
let task = state
|
||||
.task(task_id)
|
||||
.with_context(|| format!("unknown task id `{task_id}`"))?;
|
||||
let runs_dir = state.workspace_dir().join(RUNS_DIR);
|
||||
fs::create_dir_all(&runs_dir)?;
|
||||
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
|
||||
let file_name = format!("{task_id}-{timestamp}.md");
|
||||
let path = runs_dir.join(&file_name);
|
||||
let plan = rel_to_root(&state.plan_path(), root);
|
||||
let journal = rel_to_root(&state.journal_path(), root);
|
||||
let mut body = format!(
|
||||
"# Task {task_id}: {}\n\n{}\n\n## Checkpoints\n",
|
||||
task.title, task.description
|
||||
);
|
||||
for checkpoint in &task.checkpoints {
|
||||
body.push_str(&format!("- {checkpoint}\n"));
|
||||
}
|
||||
body.push_str(&format!(
|
||||
"\nPublish updates to `{journal}`. Mirror final scope into `{plan}` when it changes.\n"
|
||||
));
|
||||
fs::write(&path, body)?;
|
||||
Ok(rel_to_root(&path, root))
|
||||
}
|
||||
|
||||
fn package_dir(root: &Path) -> PathBuf {
|
||||
root.join(PACKAGE_DIR)
|
||||
}
|
||||
|
||||
fn manifest_path(root: &Path) -> PathBuf {
|
||||
package_dir(root).join(MANIFEST_FILE)
|
||||
}
|
||||
|
||||
fn index_path(root: &Path) -> PathBuf {
|
||||
package_dir(root).join(INDEX_FILE)
|
||||
}
|
||||
|
||||
fn rel_to_root(path: &Path, root: &Path) -> String {
|
||||
diff_paths(path, root)
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn write_manifest(path: &Path, manifest: &MigrationManifest) -> Result<()> {
|
||||
let text = toml::to_string_pretty(manifest)?;
|
||||
fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pretty_json(path: &Path, value: &impl Serialize) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value)?;
|
||||
fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_package_readme(root: &Path) -> Result<()> {
|
||||
let contents = format!(
|
||||
"# {PACKAGE_NAME}\n\nThis folder stores migration metadata for Codex.\n\n- `manifest.toml` controls connectors + MCPs\n- `index.json` tracks active migrations\n\nEdit the manifest when you add tooling, then re-run `codex migrate plan`.\n"
|
||||
);
|
||||
fs::write(package_dir(root).join("README.md"), contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MigrationManifest {
|
||||
package: String,
|
||||
version: u32,
|
||||
created_at: String,
|
||||
#[serde(default)]
|
||||
connectors: Vec<String>,
|
||||
#[serde(default)]
|
||||
mcps: Vec<String>,
|
||||
notes: String,
|
||||
#[serde(default)]
|
||||
last_workspace: Option<String>,
|
||||
}
|
||||
|
||||
impl MigrationManifest {
|
||||
fn new(connectors: Vec<String>, mcps: Vec<String>) -> Self {
|
||||
Self {
|
||||
package: PACKAGE_NAME.to_string(),
|
||||
version: STATE_VERSION,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
connectors,
|
||||
mcps,
|
||||
notes: "Update connectors + MCPs here so Codex knows what tooling is in play."
|
||||
.to_string(),
|
||||
last_workspace: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_manifest(root: &Path) -> Result<MigrationManifest> {
|
||||
let text = fs::read_to_string(manifest_path(root))
|
||||
.context("Missing migration manifest. Run `codex migrate setup` first.")?;
|
||||
let manifest: MigrationManifest = toml::from_str(&text)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct MigrationIndexEntry {
|
||||
slug: String,
|
||||
summary: String,
|
||||
workspace: String,
|
||||
plan: String,
|
||||
journal: String,
|
||||
tasks_path: String,
|
||||
pending_tasks: usize,
|
||||
running_tasks: usize,
|
||||
blocked_tasks: usize,
|
||||
ready_parallel_tasks: Vec<String>,
|
||||
status: IndexStatus,
|
||||
updated_at: String,
|
||||
updated_at_epoch: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum IndexStatus {
|
||||
Planning,
|
||||
Executing,
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct MigrationIndex {
|
||||
version: u32,
|
||||
migrations: Vec<MigrationIndexEntry>,
|
||||
}
|
||||
|
||||
impl Default for MigrationIndex {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: INDEX_VERSION,
|
||||
migrations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_index(path: &Path) -> Result<MigrationIndex> {
|
||||
if path.exists() {
|
||||
let text = fs::read_to_string(path)?;
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
} else {
|
||||
Ok(MigrationIndex::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_index(root: &Path, state: &MigrationState) -> Result<()> {
|
||||
let mut index = load_index(&index_path(root))?;
|
||||
let entry = state.to_index_entry(root);
|
||||
index
|
||||
.migrations
|
||||
.retain(|existing| existing.slug != entry.slug || existing.workspace != entry.workspace);
|
||||
index.migrations.push(entry);
|
||||
write_pretty_json(&index_path(root), &index)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
enum TaskStatus {
|
||||
#[default]
|
||||
Pending,
|
||||
Running,
|
||||
Blocked,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TaskStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let label = match self {
|
||||
TaskStatus::Pending => "pending",
|
||||
TaskStatus::Running => "running",
|
||||
TaskStatus::Blocked => "blocked",
|
||||
TaskStatus::Done => "done",
|
||||
};
|
||||
write!(f, "{label}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct MigrationTask {
|
||||
id: String,
|
||||
title: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
status: TaskStatus,
|
||||
#[serde(default)]
|
||||
depends_on: Vec<String>,
|
||||
#[serde(default)]
|
||||
parallel_group: Option<String>,
|
||||
#[serde(default)]
|
||||
owner_hint: Option<String>,
|
||||
#[serde(default)]
|
||||
publish_to: Vec<String>,
|
||||
#[serde(default)]
|
||||
checkpoints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct MigrationStateFile {
|
||||
version: u32,
|
||||
summary: String,
|
||||
slug: String,
|
||||
plan_path: String,
|
||||
journal_path: String,
|
||||
#[serde(default)]
|
||||
connectors: Vec<String>,
|
||||
tasks: Vec<MigrationTask>,
|
||||
}
|
||||
|
||||
struct MigrationState {
|
||||
file: MigrationStateFile,
|
||||
workspace_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl MigrationState {
|
||||
fn new(
|
||||
summary: String,
|
||||
workspace: &MigrationWorkspace,
|
||||
connectors: Vec<String>,
|
||||
parallel: usize,
|
||||
) -> Self {
|
||||
let tasks = seed_tasks(&summary, &connectors, parallel);
|
||||
Self {
|
||||
file: MigrationStateFile {
|
||||
version: STATE_VERSION,
|
||||
summary,
|
||||
slug: workspace.dir_name.clone(),
|
||||
plan_path: "plan.md".to_string(),
|
||||
journal_path: "journal.md".to_string(),
|
||||
connectors,
|
||||
tasks,
|
||||
},
|
||||
workspace_dir: workspace.dir_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(workspace_dir: PathBuf) -> Result<Self> {
|
||||
let data_path = workspace_dir.join(TASKS_FILE);
|
||||
let text = fs::read_to_string(&data_path)
|
||||
.with_context(|| format!("missing tasks file at {}", data_path.display()))?;
|
||||
let file: MigrationStateFile = serde_json::from_str(&text)?;
|
||||
Ok(Self {
|
||||
file,
|
||||
workspace_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn save(&self) -> Result<()> {
|
||||
write_pretty_json(&self.workspace_dir.join(TASKS_FILE), &self.file)
|
||||
}
|
||||
|
||||
fn workspace_dir(&self) -> &Path {
|
||||
&self.workspace_dir
|
||||
}
|
||||
|
||||
fn plan_path(&self) -> PathBuf {
|
||||
self.workspace_dir.join(&self.file.plan_path)
|
||||
}
|
||||
|
||||
fn journal_path(&self) -> PathBuf {
|
||||
self.workspace_dir.join(&self.file.journal_path)
|
||||
}
|
||||
|
||||
fn task(&self, id: &str) -> Option<&MigrationTask> {
|
||||
self.file.tasks.iter().find(|task| task.id == id)
|
||||
}
|
||||
|
||||
fn task_mut(&mut self, id: &str) -> Option<&mut MigrationTask> {
|
||||
self.file.tasks.iter_mut().find(|task| task.id == id)
|
||||
}
|
||||
|
||||
fn set_status(&mut self, id: &str, status: TaskStatus) -> Result<()> {
|
||||
let task = self
|
||||
.task_mut(id)
|
||||
.with_context(|| format!("unknown task id `{id}`"))?;
|
||||
task.status = status;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_runnable_task_id(&self) -> Option<String> {
|
||||
self.file
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.status == TaskStatus::Pending && self.dependencies_met(task))
|
||||
.map(|task| task.id.clone())
|
||||
}
|
||||
|
||||
fn dependencies_met(&self, task: &MigrationTask) -> bool {
|
||||
task.depends_on.iter().all(|dep| {
|
||||
self.file
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|t| &t.id == dep)
|
||||
.map(|t| t.status == TaskStatus::Done)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn can_start(&self, id: &str) -> bool {
|
||||
self.task(id)
|
||||
.map(|task| self.dependencies_met(task))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn workspace_dir_string(&self, root: &Path) -> String {
|
||||
rel_to_root(&self.workspace_dir, root)
|
||||
}
|
||||
|
||||
fn ready_parallel_tasks(&self) -> Vec<String> {
|
||||
self.file
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| task.parallel_group.is_some())
|
||||
.filter(|task| task.status == TaskStatus::Pending)
|
||||
.filter(|task| self.dependencies_met(task))
|
||||
.map(|task| task.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn status_counts(&self) -> (usize, usize, usize, usize) {
|
||||
let mut pending = 0;
|
||||
let mut running = 0;
|
||||
let mut blocked = 0;
|
||||
let mut done = 0;
|
||||
for task in &self.file.tasks {
|
||||
match task.status {
|
||||
TaskStatus::Pending => pending += 1,
|
||||
TaskStatus::Running => running += 1,
|
||||
TaskStatus::Blocked => blocked += 1,
|
||||
TaskStatus::Done => done += 1,
|
||||
}
|
||||
}
|
||||
(pending, running, blocked, done)
|
||||
}
|
||||
|
||||
fn to_index_entry(&self, root: &Path) -> MigrationIndexEntry {
|
||||
let (pending, running, blocked, _done) = self.status_counts();
|
||||
let ready_parallel_tasks = self.ready_parallel_tasks();
|
||||
let status = if pending == 0 && running == 0 && blocked == 0 {
|
||||
IndexStatus::Complete
|
||||
} else if running > 0 {
|
||||
IndexStatus::Executing
|
||||
} else {
|
||||
IndexStatus::Planning
|
||||
};
|
||||
let now = Utc::now();
|
||||
MigrationIndexEntry {
|
||||
slug: self.file.slug.clone(),
|
||||
summary: self.file.summary.clone(),
|
||||
workspace: self.workspace_dir_string(root),
|
||||
plan: rel_to_root(&self.plan_path(), root),
|
||||
journal: rel_to_root(&self.journal_path(), root),
|
||||
tasks_path: rel_to_root(&self.workspace_dir.join(TASKS_FILE), root),
|
||||
pending_tasks: pending,
|
||||
running_tasks: running,
|
||||
blocked_tasks: blocked,
|
||||
ready_parallel_tasks,
|
||||
status,
|
||||
updated_at: now.to_rfc3339(),
|
||||
updated_at_epoch: now.timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_tasks(summary: &str, connectors: &[String], parallel: usize) -> Vec<MigrationTask> {
|
||||
let mut tasks = Vec::new();
|
||||
let plan_targets = vec!["plan.md".to_string(), "journal.md".to_string()];
|
||||
tasks.push(MigrationTask {
|
||||
id: "plan-baseline".to_string(),
|
||||
title: "Map current + target states".to_string(),
|
||||
description: format!(
|
||||
"Capture why `{summary}` is needed, current system contracts, and the desired end state in `plan.md`."
|
||||
),
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Document repositories, services, and owners".to_string(),
|
||||
"List non-negotiable constraints".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
tasks.push(MigrationTask {
|
||||
id: "plan-guardrails".to_string(),
|
||||
title: "Design guardrails + approvals".to_string(),
|
||||
description: "Spell out kill-switches, approvals, and telemetry gating.".to_string(),
|
||||
depends_on: vec!["plan-baseline".to_string()],
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Define approval owners".to_string(),
|
||||
"List telemetry + alerting hooks".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
tasks.push(MigrationTask {
|
||||
id: "plan-blueprint".to_string(),
|
||||
title: "Lock incremental rollout plan".to_string(),
|
||||
description: "Lay out the numbered steps and decision records for the migration."
|
||||
.to_string(),
|
||||
depends_on: vec!["plan-guardrails".to_string()],
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Identify sequencing + dependencies".to_string(),
|
||||
"Assign owners to each increment".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut sources: Vec<String> = if connectors.is_empty() {
|
||||
(1..=parallel).map(|i| format!("workstream #{i}")).collect()
|
||||
} else {
|
||||
connectors.to_vec()
|
||||
};
|
||||
if sources.is_empty() {
|
||||
sources.push("workstream #1".to_string());
|
||||
}
|
||||
sources.truncate(parallel.max(1));
|
||||
|
||||
for (idx, source) in sources.iter().enumerate() {
|
||||
tasks.push(MigrationTask {
|
||||
id: format!("parallel-scout-{}", idx + 1),
|
||||
title: format!("Deep-dive: {source}"),
|
||||
description: format!(
|
||||
"Inventory blockers, data contracts, and automation opportunities for `{source}`. Feed findings into journal.md and update plan.md if scope shifts."
|
||||
),
|
||||
depends_on: vec!["plan-blueprint".to_string()],
|
||||
parallel_group: Some("exploration".to_string()),
|
||||
owner_hint: Some("domain expert".to_string()),
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Publish progress + artifacts to journal.md".to_string(),
|
||||
"Flag shared learnings for other workstreams".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
tasks.push(MigrationTask {
|
||||
id: "parallel-telemetry".to_string(),
|
||||
title: "Build shared telemetry + rehearsal harness".to_string(),
|
||||
description:
|
||||
"Codify validation scripts, load tests, and dashboards each workstream will reuse."
|
||||
.to_string(),
|
||||
depends_on: vec!["plan-blueprint".to_string()],
|
||||
parallel_group: Some("stabilization".to_string()),
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Link dashboards in journal.md".to_string(),
|
||||
"Tag required signals per task".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
tasks.push(MigrationTask {
|
||||
id: "parallel-backfill".to_string(),
|
||||
title: "Design data backfill + rollback story".to_string(),
|
||||
description: "Document backfill tooling, rehearsal cadence, and rollback triggers."
|
||||
.to_string(),
|
||||
depends_on: vec!["plan-blueprint".to_string()],
|
||||
parallel_group: Some("stabilization".to_string()),
|
||||
publish_to: plan_targets.clone(),
|
||||
checkpoints: vec![
|
||||
"Note dry-run schedule in journal.md".to_string(),
|
||||
"List reversibility safeguards".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut cutover_dependencies = vec![
|
||||
"plan-baseline".to_string(),
|
||||
"plan-guardrails".to_string(),
|
||||
"plan-blueprint".to_string(),
|
||||
"parallel-telemetry".to_string(),
|
||||
"parallel-backfill".to_string(),
|
||||
];
|
||||
cutover_dependencies.extend(
|
||||
sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| format!("parallel-scout-{}", idx + 1)),
|
||||
);
|
||||
|
||||
tasks.push(MigrationTask {
|
||||
id: "plan-cutover".to_string(),
|
||||
title: "Execute rollout + capture learnings".to_string(),
|
||||
description: "Drive the migration, capture deviations, and publish the final hand-off."
|
||||
.to_string(),
|
||||
depends_on: cutover_dependencies,
|
||||
publish_to: plan_targets,
|
||||
checkpoints: vec![
|
||||
"Attach final verification evidence".to_string(),
|
||||
"Document kill-switch + rollback state".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
tasks
|
||||
}
|
||||
|
||||
impl Default for MigrationTask {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
status: TaskStatus::Pending,
|
||||
depends_on: Vec::new(),
|
||||
parallel_group: None,
|
||||
owner_hint: None,
|
||||
publish_to: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_readme() -> String {
|
||||
format!(
|
||||
"# Codex migration dashboard\n\nThis static UI reads `.codex/migrate/index.json`. Serve the **repo root** (example: `npx http-server . -c-1 -p 4173`) then open `http://localhost:4173/{UI_DIR}/`.\n\nFeel free to edit `app.js` or `styles.css` — ask Codex to customize this UI when you need richer controls."
|
||||
)
|
||||
}
|
||||
|
||||
fn ui_index_html() -> String {
|
||||
"<!doctype html>\n<html lang=\"en\">\n<meta charset=\"utf-8\">\n<title>Codex migration dashboard</title>\n<link rel=\"stylesheet\" href=\"./styles.css\">\n<body>\n<main id=\"app\">Loading migration dashboard...</main>\n<script type=\"module\" src=\"./app.js\"></script>\n</body>\n</html>\n".to_string()
|
||||
}
|
||||
|
||||
fn ui_styles() -> String {
|
||||
"body { font-family: system-ui, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 2rem; }\nmain { display: grid; gap: 1rem; }\n.card { background: #1e293b; border-radius: 12px; padding: 1rem; box-shadow: 0 10px 25px rgba(0,0,0,0.25); }\n.card h2 { margin-top: 0; }\n.badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; }\n.badge.planning { background: #1d4ed8; }\n.badge.executing { background: #f97316; }\n.badge.complete { background: #16a34a; }\nul { padding-left: 1.2rem; }\n".to_string()
|
||||
}
|
||||
|
||||
fn ui_script() -> String {
|
||||
r#"const app = document.getElementById('app');
|
||||
|
||||
async function loadIndex() {
|
||||
try {
|
||||
const res = await fetch('../.codex/migrate/index.json?cache=' + Date.now());
|
||||
if (!res.ok) throw new Error('Failed to load index.json');
|
||||
const data = await res.json();
|
||||
render(data);
|
||||
} catch (err) {
|
||||
app.innerHTML = `<div class="card">${err.message}. Serve the repo root and retry.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
if (!data.migrations || data.migrations.length === 0) {
|
||||
app.innerHTML = '<div class="card">No migrations yet. Run <code>codex migrate plan</code>.</div>';
|
||||
return;
|
||||
}
|
||||
app.innerHTML = '';
|
||||
data.migrations
|
||||
.sort((a, b) => b.updated_at_epoch - a.updated_at_epoch)
|
||||
.forEach((migration) => {
|
||||
const card = document.createElement('section');
|
||||
card.className = 'card';
|
||||
const status = migration.status || 'planning';
|
||||
card.innerHTML = `
|
||||
<h2>${migration.summary}</h2>
|
||||
<span class="badge ${status}">${status}</span>
|
||||
<p><strong>Workspace:</strong> ${migration.workspace}</p>
|
||||
<p><strong>Plan:</strong> ${migration.plan} · <strong>Journal:</strong> ${migration.journal}</p>
|
||||
<p>${migration.pending_tasks} pending · ${migration.running_tasks} running · ${migration.blocked_tasks} blocked</p>
|
||||
<p><strong>Ready in parallel:</strong> ${(migration.ready_parallel_tasks || []).join(', ') || '—'}</p>
|
||||
<p><em>Updated ${new Date(migration.updated_at).toLocaleString()}</em></p>
|
||||
`;
|
||||
app.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
loadIndex();
|
||||
setInterval(loadIndex, 8000);
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn next_task_unlocked_after_dependencies_complete() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = MigrationWorkspace {
|
||||
dir_path: tmp.path().to_path_buf(),
|
||||
dir_name: "migration_demo".to_string(),
|
||||
plan_path: tmp.path().join("plan.md"),
|
||||
journal_path: tmp.path().join("journal.md"),
|
||||
};
|
||||
fs::write(&workspace.plan_path, "plan").unwrap();
|
||||
fs::write(&workspace.journal_path, "journal").unwrap();
|
||||
let connectors = vec!["billing-db".to_string()];
|
||||
let mut state = MigrationState::new("Demo".to_string(), &workspace, connectors, 1);
|
||||
assert_eq!(
|
||||
state.next_runnable_task_id().as_deref(),
|
||||
Some("plan-baseline")
|
||||
);
|
||||
state.set_status("plan-baseline", TaskStatus::Done).unwrap();
|
||||
state
|
||||
.set_status("plan-guardrails", TaskStatus::Done)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.next_runnable_task_id().as_deref(),
|
||||
Some("plan-blueprint")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_parallel_tasks_wait_for_blueprint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = MigrationWorkspace {
|
||||
dir_path: tmp.path().to_path_buf(),
|
||||
dir_name: "migration_demo".to_string(),
|
||||
plan_path: tmp.path().join("plan.md"),
|
||||
journal_path: tmp.path().join("journal.md"),
|
||||
};
|
||||
fs::write(&workspace.plan_path, "plan").unwrap();
|
||||
fs::write(&workspace.journal_path, "journal").unwrap();
|
||||
let connectors = vec!["auth-service".to_string(), "search".to_string()];
|
||||
let mut state = MigrationState::new("Demo".to_string(), &workspace, connectors, 2);
|
||||
assert!(state.ready_parallel_tasks().is_empty());
|
||||
state.set_status("plan-baseline", TaskStatus::Done).unwrap();
|
||||
state
|
||||
.set_status("plan-guardrails", TaskStatus::Done)
|
||||
.unwrap();
|
||||
state
|
||||
.set_status("plan-blueprint", TaskStatus::Done)
|
||||
.unwrap();
|
||||
let ready = state.ready_parallel_tasks();
|
||||
let ready_set: std::collections::HashSet<_> = ready.into_iter().collect();
|
||||
let expected = std::collections::HashSet::from([
|
||||
"parallel-scout-1".to_string(),
|
||||
"parallel-scout-2".to_string(),
|
||||
"parallel-telemetry".to_string(),
|
||||
"parallel-backfill".to_string(),
|
||||
]);
|
||||
assert_eq!(ready_set, expected);
|
||||
}
|
||||
}
|
||||
16
codex-rs/tui/migration_journal_template.md
Normal file
16
codex-rs/tui/migration_journal_template.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Migration journal – {{MIGRATION_SUMMARY}}
|
||||
|
||||
> Workspace: `{{WORKSPACE_NAME}}`
|
||||
> Created: {{CREATED_AT}}
|
||||
|
||||
Use this log for async updates, agent hand-offs, and to publish what was learned during each workstream. Keep entries concise and focused on signals other collaborators need.
|
||||
|
||||
## Logging guidance
|
||||
- Start each entry with a timestamp and author/agent/workstream name.
|
||||
- Capture what changed, how it was validated, links to diffs/tests, and any open questions.
|
||||
- Highlight blockers, decisions needed, or knowledge that other agents should adopt.
|
||||
- Update the plan (`plan.md`) when scope changes; use this journal for progress + lessons.
|
||||
|
||||
| Timestamp | Agent / Workstream | Update / Learnings | Blockers & Risks | Next action / owner |
|
||||
| --------- | ------------------ | ------------------ | ---------------- | ------------------- |
|
||||
| | | | | |
|
||||
35
codex-rs/tui/migration_plan_template.md
Normal file
35
codex-rs/tui/migration_plan_template.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Migration plan – {{MIGRATION_SUMMARY}}
|
||||
|
||||
> Workspace: `{{WORKSPACE_NAME}}`
|
||||
> Generated: {{CREATED_AT}}
|
||||
|
||||
Use this document as the single source of truth for the migration effort. Keep it updated so any engineer (or agent) can jump in mid-flight.
|
||||
|
||||
## 1. Context & stakes
|
||||
- Current state snapshot
|
||||
- Target end state and deadline/launch windows
|
||||
- Guardrails, SLAs, compliance/regulatory constraints
|
||||
|
||||
## 2. Incremental plan (numbered)
|
||||
1. `[Step name]` — Purpose, scope, primary owner/skillset, upstream/downstream dependencies, validation & rollback signals.
|
||||
2. `…`
|
||||
|
||||
Each step must explain:
|
||||
- Preconditions & artifacts required before starting
|
||||
- Specific code/data/infrastructure changes
|
||||
- Telemetry, tests, or dry-runs that prove success
|
||||
|
||||
## 3. Parallel workstreams
|
||||
| Workstream | Objective | Inputs & dependencies | Ownership / skills | Progress & telemetry hooks |
|
||||
| ---------- | --------- | --------------------- | ------------------ | ------------------------- |
|
||||
| _Fill in during planning_ | | | | |
|
||||
|
||||
## 4. Data + rollout considerations
|
||||
- Data migration / backfill plan
|
||||
- Environment readiness, feature flags, or config toggles
|
||||
- Rollout plan (phases, smoke tests, canaries) and explicit rollback/kill-switch criteria
|
||||
|
||||
## 5. Risks, decisions, and follow-ups
|
||||
- Top risks with mitigation owners
|
||||
- Open questions / decisions with DRI and due date
|
||||
- Handoff expectations (reference `journal.md` for ongoing updates)
|
||||
25
codex-rs/tui/prompt_for_migrate_command.md
Normal file
25
codex-rs/tui/prompt_for_migrate_command.md
Normal file
@@ -0,0 +1,25 @@
|
||||
You are the migration showrunner orchestrating "{{MIGRATION_SUMMARY}}".
|
||||
|
||||
Workspace root: {{WORKSPACE_PATH}}
|
||||
Plan document to maintain: {{PLAN_PATH}}
|
||||
Shared journal for progress + learnings: {{JOURNAL_PATH}}
|
||||
|
||||
Before writing any code, study the repository layout, dependencies, release process, deployment gates, data stores, and cross-team stakeholders.
|
||||
|
||||
Deliverables:
|
||||
1. Start with a concise **Executive Overview** summarizing the target state, key risks, and the biggest unknowns.
|
||||
2. Provide an **Incremental plan** as a numbered list (1., 2., 3., …). Each item must include:
|
||||
- Objective and concrete changes required.
|
||||
- Dependencies or prerequisites, including approvals and data/state migrations.
|
||||
- Ownership expectations (skillsets/teams) and automation hooks.
|
||||
- Validation signals, telemetry, and rollback/kill-switch instructions.
|
||||
3. Add a **Parallel workstreams** table. Group tasks that can run concurrently, show how learnings are exchanged, and specify when progress should be published to {{JOURNAL_PATH}}.
|
||||
4. Capture **Coordination & learning loops**: how agents collaborate, what artifacts live in {{PLAN_PATH}} vs. {{JOURNAL_PATH}}, and how to keep successors unblocked.
|
||||
5. Outline a **Risk / data / rollout** section covering backfills, environment sequencing, feature flags, monitoring, and fallback criteria.
|
||||
|
||||
General guidance:
|
||||
- Call out missing information explicitly and request the files, owners, or metrics needed to proceed.
|
||||
- Highlight dependencies on other teams, scheduled freezes, or compliance gates.
|
||||
- Emphasize opportunities for automation, reuse of tooling, and knowledge sharing so multiple agents can contribute safely.
|
||||
|
||||
After sharing the plan in chat, mirror the same structure into {{PLAN_PATH}} (using apply_patch or editing tools) so it remains the canonical artifact. Encourage collaborators to keep {{JOURNAL_PATH}} updated with progress, blockers, and learnings.
|
||||
@@ -450,6 +450,9 @@ impl App {
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
self.chat_widget.show_review_custom_prompt();
|
||||
}
|
||||
AppEvent::StartMigration { summary } => {
|
||||
self.chat_widget.start_migration(summary);
|
||||
}
|
||||
AppEvent::FullScreenApprovalRequest(request) => match request {
|
||||
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
||||
let _ = tui.enter_alt_screen();
|
||||
|
||||
@@ -102,6 +102,11 @@ pub(crate) enum AppEvent {
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Kick off the `/migrate` workflow after the user names the migration.
|
||||
StartMigration {
|
||||
summary: String,
|
||||
},
|
||||
|
||||
/// Open the approval popup.
|
||||
FullScreenApprovalRequest(ApprovalRequest),
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::migration::build_migration_prompt;
|
||||
use crate::migration::create_migration_workspace;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::onboarding::WSL_INSTRUCTIONS;
|
||||
use crate::render::Insets;
|
||||
@@ -120,6 +122,7 @@ use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use pathdiff::diff_paths;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
|
||||
@@ -1234,6 +1237,9 @@ impl ChatWidget {
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
self.submit_user_message(INIT_PROMPT.to_string().into());
|
||||
}
|
||||
SlashCommand::Migrate => {
|
||||
self.open_migrate_prompt();
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
self.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
@@ -2422,6 +2428,63 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn start_migration(&mut self, summary: String) {
|
||||
let summary = summary.trim();
|
||||
if summary.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match create_migration_workspace(&self.config.cwd, summary) {
|
||||
Ok(workspace) => {
|
||||
let dir_display = self.relative_display_path(&workspace.dir_path);
|
||||
let plan_display = self.relative_display_path(&workspace.plan_path);
|
||||
let journal_display = self.relative_display_path(&workspace.journal_path);
|
||||
let prompt =
|
||||
build_migration_prompt(summary, &dir_display, &plan_display, &journal_display);
|
||||
let hint = format!("Plan: {plan_display}\nJournal: {journal_display}",);
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Created migration workspace `{}` at {dir_display}.",
|
||||
workspace.dir_name
|
||||
),
|
||||
Some(hint),
|
||||
);
|
||||
self.submit_user_message(prompt.into());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("failed to prepare migration workspace: {err}");
|
||||
self.add_error_message(format!("Failed to prepare migration workspace: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_migrate_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Describe the migration".to_string(),
|
||||
"Example: Phase 2 – move billing to Postgres".to_string(),
|
||||
Some(
|
||||
"We'll create migration_<slug> with plan.md and journal.md once you press Enter."
|
||||
.to_string(),
|
||||
),
|
||||
Box::new(move |prompt: String| {
|
||||
let trimmed = prompt.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
tx.send(AppEvent::StartMigration { summary: trimmed });
|
||||
}),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn relative_display_path(&self, path: &Path) -> String {
|
||||
diff_paths(path, &self.config.cwd)
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> TokenUsage {
|
||||
self.token_info
|
||||
.as_ref()
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_render;
|
||||
mod markdown_stream;
|
||||
pub mod migration;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
|
||||
125
codex-rs/tui/src/migration.rs
Normal file
125
codex-rs/tui/src/migration.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use chrono::Local;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const MIGRATION_PROMPT_TEMPLATE: &str = include_str!("../prompt_for_migrate_command.md");
|
||||
const MIGRATION_PLAN_TEMPLATE: &str = include_str!("../migration_plan_template.md");
|
||||
const MIGRATION_JOURNAL_TEMPLATE: &str = include_str!("../migration_journal_template.md");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MigrationWorkspace {
|
||||
pub dir_path: PathBuf,
|
||||
pub dir_name: String,
|
||||
pub plan_path: PathBuf,
|
||||
pub journal_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn create_migration_workspace(
|
||||
base_dir: &Path,
|
||||
summary: &str,
|
||||
) -> Result<MigrationWorkspace, std::io::Error> {
|
||||
fs::create_dir_all(base_dir)?;
|
||||
let slug = sanitize_migration_slug(summary);
|
||||
let base_name = format!("migration_{slug}");
|
||||
let (dir_path, dir_name) = next_available_migration_dir(base_dir, &base_name);
|
||||
fs::create_dir_all(&dir_path)?;
|
||||
let created_at = Local::now().format("%Y-%m-%d %H:%M %Z").to_string();
|
||||
let plan_path = dir_path.join("plan.md");
|
||||
let journal_path = dir_path.join("journal.md");
|
||||
let replacements = [
|
||||
("{{MIGRATION_SUMMARY}}", summary),
|
||||
("{{WORKSPACE_NAME}}", dir_name.as_str()),
|
||||
("{{CREATED_AT}}", created_at.as_str()),
|
||||
];
|
||||
let plan_contents = fill_template(MIGRATION_PLAN_TEMPLATE, &replacements);
|
||||
let journal_contents = fill_template(MIGRATION_JOURNAL_TEMPLATE, &replacements);
|
||||
fs::write(&plan_path, plan_contents)?;
|
||||
fs::write(&journal_path, journal_contents)?;
|
||||
Ok(MigrationWorkspace {
|
||||
dir_path,
|
||||
dir_name,
|
||||
plan_path,
|
||||
journal_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_migration_prompt(
|
||||
summary: &str,
|
||||
workspace_display: &str,
|
||||
plan_display: &str,
|
||||
journal_display: &str,
|
||||
) -> String {
|
||||
let replacements = [
|
||||
("{{MIGRATION_SUMMARY}}", summary),
|
||||
("{{WORKSPACE_PATH}}", workspace_display),
|
||||
("{{PLAN_PATH}}", plan_display),
|
||||
("{{JOURNAL_PATH}}", journal_display),
|
||||
];
|
||||
fill_template(MIGRATION_PROMPT_TEMPLATE, &replacements)
|
||||
}
|
||||
|
||||
pub fn sanitize_migration_slug(summary: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = true;
|
||||
for ch in summary.trim().to_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
let mut trimmed = slug.trim_matches('-').to_string();
|
||||
if trimmed.len() > 48 {
|
||||
trimmed = trimmed
|
||||
.chars()
|
||||
.take(48)
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
return Local::now().format("plan-%Y%m%d-%H%M%S").to_string();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
fn next_available_migration_dir(base_dir: &Path, base_name: &str) -> (PathBuf, String) {
|
||||
let mut candidate_name = base_name.to_string();
|
||||
let mut candidate_path = base_dir.join(&candidate_name);
|
||||
let mut suffix = 2;
|
||||
while candidate_path.exists() {
|
||||
candidate_name = format!("{base_name}_{suffix:02}");
|
||||
candidate_path = base_dir.join(&candidate_name);
|
||||
suffix += 1;
|
||||
}
|
||||
(candidate_path, candidate_name)
|
||||
}
|
||||
|
||||
fn fill_template(template: &str, replacements: &[(&str, &str)]) -> String {
|
||||
let mut filled = template.to_string();
|
||||
for (needle, value) in replacements {
|
||||
filled = filled.replace(needle, value);
|
||||
}
|
||||
filled
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_migration_slug;
|
||||
|
||||
#[test]
|
||||
fn slug_sanitizes_whitespace_and_length() {
|
||||
let slug = sanitize_migration_slug(" Launch 🚀 Phase #2 migration :: Big Refactor ");
|
||||
assert_eq!(slug, "launch-phase-2-migration-big-refactor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_falls_back_to_timestamp() {
|
||||
let slug = sanitize_migration_slug(" ");
|
||||
assert!(slug.starts_with("plan-"));
|
||||
assert!(slug.len() > 10);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub enum SlashCommand {
|
||||
Review,
|
||||
New,
|
||||
Init,
|
||||
Migrate,
|
||||
Compact,
|
||||
Undo,
|
||||
Diff,
|
||||
@@ -39,6 +40,7 @@ impl SlashCommand {
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Migrate => "spin up a migration workspace and planning prompt",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
@@ -65,6 +67,7 @@ impl SlashCommand {
|
||||
match self {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Migrate
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Undo
|
||||
| SlashCommand::Model
|
||||
|
||||
71
docs/migrations.md
Normal file
71
docs/migrations.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Codex migrations
|
||||
|
||||
Codex now ships an opinionated `@codex/migrate` package that sets up manifests, task graphs, and UI helpers so multi-agent migrations follow the same playbook. The workflow combines dedicated CLI commands with the `/migrate` slash command inside the TUI.
|
||||
|
||||
## 1. Install the package
|
||||
|
||||
Run the setup command once per repository:
|
||||
|
||||
```bash
|
||||
codex migrate setup \
|
||||
--connector source-db \
|
||||
--connector billing-api \
|
||||
--mcp internal-secrets
|
||||
```
|
||||
|
||||
This creates `.codex/migrate/manifest.toml` (edit it any time connectors or MCP servers change) plus an empty `.codex/migrate/index.json` registry. Use `--force` to re-initialize.
|
||||
|
||||
## 2. Generate a plan + workspace
|
||||
|
||||
Describe the migration and let Codex scaffold the workspace:
|
||||
|
||||
```bash
|
||||
codex migrate plan "Phase 2 – move billing to Postgres"
|
||||
```
|
||||
|
||||
The command:
|
||||
|
||||
1. Creates `migrations/migration_<slug>` with `plan.md`, `journal.md`, `tasks.json`, `prompt.txt`, and a `runs/` directory for per-task runbooks.
|
||||
2. Seeds a task graph that separates gating steps from parallel workstreams (`parallel-scout-*`, `parallel-telemetry`, etc.).
|
||||
3. Updates `.codex/migrate/index.json` so dashboards and teammates can discover the workspace.
|
||||
|
||||
Open the workspace in Codex and run `/migrate` to load the system prompt and mirror the plan into `plan.md` automatically. The generated `prompt.txt` is the same set of instructions if you want to paste it into another agent.
|
||||
|
||||
## 3. Drive execution
|
||||
|
||||
Use `codex migrate execute` to orchestrate tasks:
|
||||
|
||||
- `codex migrate execute` – pick the next runnable task, mark it `running`, and drop a runbook under `runs/`.
|
||||
- `codex migrate execute TASK_ID --status done` – manually advance or unblock a task.
|
||||
- `codex migrate execute TASK_ID --note "published dry-run #2"` – append a row to `journal.md` while updating the task.
|
||||
- `codex migrate execute --workspace migrations/migration_slug …` – target a specific workspace (defaults to the most recent one).
|
||||
|
||||
The command enforces dependencies, updates `.codex/migrate/index.json`, and keeps `tasks.json` in sync so multiple operators (or agents) can share state.
|
||||
|
||||
## 4. Spin up the dashboard
|
||||
|
||||
Generate a lightweight dashboard scaffold that visualizes `index.json`:
|
||||
|
||||
```bash
|
||||
codex migrate ui init
|
||||
```
|
||||
|
||||
Serve the repository root (for example `npx http-server . -c-1 -p 4173`) and open `http://localhost:4173/migration-ui/`. The vanilla HTML/JS/CSS app polls `.codex/migrate/index.json`, showing statuses, ready-to-run parallel tasks, and the latest workspace metadata. Ask Codex to edit `migration-ui/app.js` or `styles.css` to customize it.
|
||||
|
||||
## Working with `/migrate`
|
||||
|
||||
Inside the TUI, `/migrate` prompts you for a short description, creates the same `plan.md`/`journal.md` artifacts, and sends the engineered migration prompt to the agent. When paired with the CLI workflow:
|
||||
|
||||
- Use `codex migrate plan` to bootstrap directories + tasks, then `/migrate` to populate the plan and executive summary.
|
||||
- Keep `plan.md` as the canonical blueprint and `journal.md` for hand-offs or updates (the slash command reminds collaborators of this split).
|
||||
- Use the CLI `execute` command whenever you want to launch or reassign a task—its runbooks include the same context you would otherwise paste manually.
|
||||
|
||||
## Quick reference to share with teammates
|
||||
|
||||
1. `codex migrate setup --connector <system>` – install the package + manifest.
|
||||
2. `codex migrate plan "<description>"` – create `migration_<slug>` with plan/journal/tasks and prompt.
|
||||
3. `codex migrate execute [task_id] [--status … --note …]` – mark tasks running/done and log updates.
|
||||
4. `codex migrate ui init` – scaffold a dashboard that reads `.codex/migrate/index.json`.
|
||||
5. Open the workspace in Codex and run `/migrate` whenever you need the agent to refresh the plan or produce new coordination artifacts.
|
||||
|
||||
Because every artifact lives inside the repo, you can commit the plan, journal, `tasks.json`, runbooks, and UI changes to collaborate asynchronously or ask Codex to evolve the dashboard itself.
|
||||
@@ -17,6 +17,7 @@ Control Codex’s behavior during an interactive session with slash commands.
|
||||
| `/review` | review my current changes and find issues |
|
||||
| `/new` | start a new chat during a conversation |
|
||||
| `/init` | create an AGENTS.md file with instructions for Codex |
|
||||
| `/migrate` | spin up a migration workspace, sync with `codex migrate`, and prompt Codex to plan it |
|
||||
| `/compact` | summarize conversation to prevent hitting the context limit |
|
||||
| `/undo` | ask Codex to undo a turn |
|
||||
| `/diff` | show git diff (including untracked files) |
|
||||
|
||||
Reference in New Issue
Block a user