Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions scripts/data-migrator-report.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { reportRows } from '../src/apps/data-migrator/ui/report.mjs';

const text = { sessions: 'Sessions', workspaces: 'Workspaces', assistantDirectories: 'Assistants', imported: 'imported', staged: 'staged', skipped: 'skipped', itemCountsUnavailable: 'unavailable' };
const report = { domainResults: [{ domain: 'workspace_sessions', state: 'verified', imported: 175, skipped: 173, warnings: [{ severity: 'info', code: 'session_path_not_migrated' }] }] };
const counts = { sessions: { imported: 143, skipped: 0 }, workspaces: { imported: 30, skipped: 0 }, assistantDirectories: { imported: 2, skipped: 0 } };
test('renders separate entity counts and hides auxiliary exclusions', () => {
assert.deepEqual(reportRows(report, counts, text), [
['Sessions', '143 imported, 0 skipped'], ['Workspaces', '30 imported, 0 skipped'], ['Assistants', '2 imported, 0 skipped'],
]);
});
test('retains real warnings and does not label staged data imported', () => {
const staged = structuredClone(report);
staged.domainResults[0].state = 'failed';
staged.domainResults[0].warnings.push({ severity: 'warning', code: 'session_source_skipped' });
const rows = reportRows(staged, counts, text);
assert.equal(rows[0][1], '143 staged, 0 skipped');
assert.deepEqual(rows.at(-1), ['workspace_sessions', 'session_source_skipped']);
});
test('missing historical manifest is explicit instead of inventing zero counts', () => {
assert.deepEqual(reportRows(report, null, text), [['Sessions', 'unavailable'], ['Workspaces', 'unavailable'], ['Assistants', 'unavailable']]);
});
1 change: 1 addition & 0 deletions src/apps/data-migrator/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ cargo test -p openbitfun-data-migrator -p openbitfun-legacy-migration-adapters -
cargo test -p openbitfun-legacy-migration --test migration_engine_contracts
node --test scripts/data-migrator-tauri-build.test.mjs
node --check src/apps/data-migrator/ui/app.js
node --test scripts/data-migrator-report.test.mjs
pnpm run theme:color-audit:all
```

Expand Down
5 changes: 5 additions & 0 deletions src/apps/data-migrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ needed for migration. ARM Windows/Linux packages are not currently produced.
5. Read the report. Sign in again or repair paths where indicated, close the
tool, and open OpenBitFun yourself.

The report lists Sessions, workspace records, and assistant directories separately.
Expected auxiliary exclusions (such as request traces and lock files) do not
contribute to these displayed counts. Historical runs use their saved manifests
for the same breakdown; missing manifests are shown as unavailable.

The UI uses the shared design-system tokens bundled offline, follows the system
light/dark/high-contrast setting, and offers English, Simplified Chinese, and
Traditional Chinese.
Expand Down
5 changes: 5 additions & 0 deletions src/apps/data-migrator/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ pub(crate) struct MigratorView {
pub findings: Vec<ScanFinding>,
pub plan: Option<MigrationPlan>,
pub report: Option<MigrationRunReport>,
pub workspace_counts: Option<openbitfun_legacy_migration_adapters::WorkspaceReportCounts>,
pub progress: Option<MigrationProgressEvent>,
pub blockers: Vec<WriterProcess>,
pub status: MigrationRunStatus,
Expand Down Expand Up @@ -794,6 +795,9 @@ fn validate_selection(selection: &MigrationSelection) -> Result<(), CommandError
fn snapshot(session: &MigratorSession) -> MigratorView {
let plan = session.plan.as_ref().map(redact_plan_for_ui);
let report = session.report.as_ref().map(redact_report_for_ui);
let workspace_counts = session.report.as_ref().and_then(|report| {
openbitfun_legacy_migration_adapters::workspace_report_counts(&session.roots, report).ok()
});
MigratorView {
tool_version: env!("CARGO_PKG_VERSION").to_string(),
locations: session.roots.clone(),
Expand All @@ -818,6 +822,7 @@ fn snapshot(session: &MigratorSession) -> MigratorView {
),
plan,
report,
workspace_counts,
progress: session.progress.clone(),
blockers: session.blockers.clone(),
status: session.status,
Expand Down
12 changes: 3 additions & 9 deletions src/apps/data-migrator/ui/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { invoke } from './transport.js';
import { reportRows } from './report.mjs';

async function loadTranslations() {
try {
Expand Down Expand Up @@ -98,10 +99,6 @@ function row(title, detail) {
return item;
}

function transferLabel(result) {
return result.state === 'verified' ? text.imported : text.staged;
}

function groupLabel(id) {
const labels = groups.find(([group]) => group === id)?.[1];
return (labels?.[locale] || labels?.en || [id])[0];
Expand Down Expand Up @@ -202,11 +199,8 @@ function render(view) {

const reportSummary = document.getElementById('report-summary');
if (view.report) {
reportSummary.replaceChildren(...view.report.domainResults.flatMap((result) => [
row(result.domain, `${result.imported} ${transferLabel(result)}, ${result.skipped} ${text.skipped}, ${result.warnings.filter((item) => item.severity !== 'info').length} ${text.warnings}`),
...[...new Set(result.warnings.filter((item) => item.severity !== 'info').map((item) => item.code))]
.map((code) => row(result.domain, text[code] || code)),
]));
reportSummary.replaceChildren(...reportRows(view.report, view.workspaceCounts, text)
.map(([title, detail]) => row(title, detail)));
}
show('report-card', !view.running && (Boolean(view.report) || view.status === 'cancelled'));
const canExportDiagnostics = ['failed_recoverable', 'failed_manual_action_required'].includes(view.status);
Expand Down
12 changes: 12 additions & 0 deletions src/apps/data-migrator/ui/locales.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"en": {
"sessions": "Sessions",
"workspaces": "Workspaces",
"assistantDirectories": "Assistant directories",
"itemCountsUnavailable": "Item counts are unavailable for this run.",
"eyebrow": "OpenBitFun Data Migrator",
"title": "Import data from BitFun",
"intro": "A separate, offline tool. Choose what to import; your original BitFun data is preserved.",
Expand Down Expand Up @@ -76,6 +80,10 @@
"extension_item_skipped": "Incomplete or unsupported extensions will be skipped; valid extensions can continue."
},
"zh-CN": {
"sessions": "会话",
"workspaces": "工作区",
"assistantDirectories": "助手目录",
"itemCountsUnavailable": "本次迁移的分类计数不可用。",
"eyebrow": "OpenBitFun 独立数据迁移器",
"title": "从 BitFun 导入数据",
"intro": "独立运行,离线导入。选择需要迁移的内容,原始 BitFun 数据会被保留。",
Expand Down Expand Up @@ -152,6 +160,10 @@
"extension_item_skipped": "将跳过不完整或不支持的扩展,其他有效扩展可继续迁移。"
},
"zh-TW": {
"sessions": "工作階段",
"workspaces": "工作區",
"assistantDirectories": "助理目錄",
"itemCountsUnavailable": "本次遷移的分類計數無法取得。",
"eyebrow": "OpenBitFun 獨立資料遷移器",
"title": "從 BitFun 匯入資料",
"intro": "獨立執行,離線匯入。選擇需要遷移的內容,原始 BitFun 資料會被保留。",
Expand Down
21 changes: 21 additions & 0 deletions src/apps/data-migrator/ui/report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function reportRows(report, workspaceCounts, text) {
return report.domainResults.flatMap((result) => {
const transferred = result.state === 'verified' ? text.imported : text.staged;
const warnings = result.warnings.filter((item) => item.severity !== 'info');
const rows = result.domain === 'workspace_sessions'
? [
['sessions', text.sessions],
['workspaces', text.workspaces],
['assistantDirectories', text.assistantDirectories],
].map(([key, label]) => {
const counts = workspaceCounts?.[key];
return [label, counts
? `${counts.imported} ${transferred}, ${counts.skipped} ${text.skipped}`
: text.itemCountsUnavailable];
})
: [[result.domain, `${result.imported} ${transferred}, ${result.skipped} ${text.skipped}, ${warnings.length} ${text.warnings}`]];
rows.push(...[...new Set(warnings.map((item) => item.code))]
.map((code) => [result.domain, text[code] || code]));
return rows;
});
}
104 changes: 101 additions & 3 deletions src/crates/assembly/core/src/service/config/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,10 +399,14 @@ impl ConfigManager {
return Err(contract_error);
}

let config: GlobalConfig = serde_json::from_value(config_value).map_err(|error| {
let mut config: GlobalConfig = serde_json::from_value(config_value).map_err(|error| {
OpenBitFunError::config(format!("Failed to deserialize config file: {error}"))
})?;
let validation_result = self.providers.validate_config(&config).await?;
let mut diagnostics =
openbitfun_config_contracts::normalization::recover_persisted_config(&mut config);
let validation_result = self
.validate_recovered_config(&mut config, &mut diagnostics)
.await?;
if !validation_result.valid {
return Err(invalid_config_error(
"Invalid configuration file",
Expand All @@ -411,11 +415,57 @@ impl ConfigManager {
}

self.config = config;
self.load_diagnostics.clear();
self.load_diagnostics = diagnostics;
debug!("Loaded OpenBitFun config from file without rewriting it");
Ok(())
}

// Catalog/provider-dependent reasoning checks cannot run in the offline
// importer. Preserve unusable model records and surface a diagnostic while
// allowing the remaining configuration to load. Each retry disables at
// least one enabled model; unrelated validation errors remain errors.
async fn validate_recovered_config(
&self,
config: &mut GlobalConfig,
diagnostics: &mut Vec<super::types::ConfigDiagnostic>,
) -> OpenBitFunResult<super::types::ConfigValidationResult> {
loop {
let result = self.providers.validate_config(config).await?;
let mut recovered = false;
for error in &result.errors {
let Some(index) = error
.path
.strip_prefix("ai.models[")
.and_then(|path| path.split_once(']'))
.and_then(|(index, _)| index.parse::<usize>().ok())
else {
continue;
};
let Some(model) = config
.ai
.models
.get_mut(index)
.filter(|model| model.enabled)
else {
continue;
};
model.enabled = false;
diagnostics.push(super::types::ConfigDiagnostic {
path: error.path.clone(),
message: error.message.clone(),
code: "CONFIG_MODEL_DISABLED".into(),
severity: super::types::ConfigDiagnosticSeverity::Warning,
recoverability: super::types::ConfigDiagnosticRecoverability::ModelDisabled,
});
recovered = true;
}
if !recovered {
return Ok(result);
}
diagnostics.extend(reconcile_model_references(config).diagnostics);
}
}

async fn try_repair_sparse_installer_config(
&mut self,
content: &str,
Expand Down Expand Up @@ -1034,6 +1084,54 @@ mod tests {
}
}

#[tokio::test]
async fn existing_config_recovers_semantics_without_rewriting_source() {
let temp = tempfile::tempdir().unwrap();
let path_manager = Arc::new(PathManager::with_user_root_for_tests(
temp.path().join("recovery"),
));
path_manager.initialize_user_directories().await.unwrap();
let mut config = GlobalConfig::default();
config.ai.stream_idle_timeout_secs = Some(0);
config.ai.default_models.primary = Some("missing".into());
config.ai.agent_model_defaults.subagents.default_selection =
super::super::types::SubagentModelSelection::fixed("missing-model");
config.app.logging.level = "invalid".into();
config.ai.models.push(super::super::types::AIModelConfig {
id: "broken".into(),
name: "Broken".into(),
provider: "openai".into(),
enabled: true,
context_window: Some(1),
api_key: "retained-secret".into(),
..Default::default()
});
let original = serde_json::to_vec(&config).unwrap();
tokio::fs::write(path_manager.app_config_file(), &original)
.await
.unwrap();
let manager = ConfigManager::new(manager_settings(path_manager.clone()))
.await
.unwrap();
assert!(!manager.config.ai.models[0].enabled);
assert_eq!(manager.config.ai.models[0].api_key, "retained-secret");
assert!(
manager
.providers
.validate_config(&manager.config)
.await
.unwrap()
.valid
);
assert!(!manager.load_diagnostics.is_empty());
assert_eq!(
tokio::fs::read(path_manager.app_config_file())
.await
.unwrap(),
original
);
}

#[tokio::test]
async fn sparse_installer_config_is_backed_up_and_repaired() {
let temp = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading