From 3b005c14faff53b02b57f85c90cbf824ea41bd7b Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 2 Aug 2026 20:12:53 +0800 Subject: [PATCH] perf(build): isolate core service capability profiles Gate Core LSP, terminal, workspace, watcher, and remote services behind additive feature profiles. Move stable workspace identity helpers into services-core, isolate libgit2-backed session support, and enforce the closed profiles with boundary checks and focused fail-closed coverage. --- scripts/core-boundaries/checker.mjs | 5 +- scripts/core-boundaries/rules/crate-rules.mjs | 22 ++ .../core-boundaries/rules/feature-rules.mjs | 121 ++++++- .../rules/source/required-rules.mjs | 75 +++- scripts/core-boundaries/self-test.mjs | 103 +++++- src/crates/assembly/core/AGENTS-CN.md | 10 + src/crates/assembly/core/AGENTS.md | 20 +- src/crates/assembly/core/Cargo.toml | 46 ++- src/crates/assembly/core/src/lib.rs | 7 +- .../core/src/service/filesystem/service.rs | 116 ++++-- src/crates/assembly/core/src/service/mod.rs | 11 + .../core/src/service/workspace/manager.rs | 4 +- .../core/src/service/workspace/mod.rs | 2 + .../core/src/service/workspace/service.rs | 24 +- .../src/service/workspace_runtime/service.rs | 8 +- src/crates/assembly/core/src/util/mod.rs | 2 + src/crates/services/services-core/AGENTS.md | 13 +- src/crates/services/services-core/Cargo.toml | 8 +- src/crates/services/services-core/src/lib.rs | 2 + .../services/services-core/src/session/mod.rs | 2 + .../services-core/src/workspace_identity.rs | 325 +++++++++++++++++ .../services/services-integrations/AGENTS.md | 10 +- .../services/services-integrations/Cargo.toml | 3 +- .../src/remote_ssh/paths.rs | 329 +----------------- 24 files changed, 851 insertions(+), 417 deletions(-) create mode 100644 src/crates/services/services-core/src/workspace_identity.rs diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 3c7e77a2b5..396ec3d35a 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -632,7 +632,10 @@ function checkClosedFeatureProfile(rule) { } const allowedLocalFeatures = new Set( - rule.requiredFeatureRefs.filter((reference) => features.has(reference)), + [ + ...rule.requiredFeatureRefs, + ...(rule.allowedTransitiveFeatureRefs ?? []), + ].filter((reference) => features.has(reference)), ); for (const unexpected of unexpectedReachableLocalFeatures( features, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 4de5514f83..0c782ffcf5 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -408,6 +408,7 @@ export const dependencyProfileRules = [ forbiddenNonOptionalDeps: [ 'aes', 'aes-gcm', + 'bitfun-services-integrations', 'bitfun-product-capabilities', 'bitfun-product-domains', 'bitfun-relay-service', @@ -431,22 +432,43 @@ export const dependencyProfileRules = [ 'local-ip-address', 'mac_address', 'md5', + 'notify', 'qrcode', 'rand', 'readability-js', 'rmcp', + 'rusqlite', 'russh', 'rustls', 'rustls-native-certs', 'schannel', 'sse-stream', 'similar', + 'serde_yaml', + 'terminal-core', 'tool-runtime', 'tokio-tungstenite', 'win32job', 'x25519-dalek', ], }, + { + crateName: 'services-core', + profileName: 'default reusable service profile', + reason: + 'services-core default profile must not compile capability-specific native or runtime implementations', + forbiddenNonOptionalDeps: [ + 'anyhow', + 'async-trait', + 'bitfun-runtime-ports', + 'dunce', + 'git2', + 'notify', + 'rusqlite', + 'serde_yaml', + 'zip', + ], + }, { crateName: 'core-types', profileName: 'default DTO profile', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 322355cd9c..18014986e4 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -4,9 +4,17 @@ export const optionalDependencyFeatureOwnerRules = [ { crateName: 'services-core', reason: - 'services-core workspace runtime dependencies must stay behind the explicit workspace-runtime feature', + 'services-core optional implementation dependencies must stay behind their exact owner capability', dependencies: [ - { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-runtime'] }, + { depName: 'anyhow', ownerFeatures: ['dispatch-workspace', 'lsp', 'workspace-runtime'] }, + { depName: 'async-trait', ownerFeatures: ['permission', 'workspace-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime'] }, + { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, + { depName: 'git2', ownerFeatures: ['session-git'] }, + { depName: 'notify', ownerFeatures: ['lsp'] }, + { depName: 'rusqlite', ownerFeatures: ['permission'] }, + { depName: 'serde_yaml', ownerFeatures: ['markdown'] }, + { depName: 'zip', ownerFeatures: ['lsp'] }, ], }, { @@ -30,6 +38,21 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, { depName: 'bitfun-runtime-services', ownerFeatures: ['runtime-services'] }, + { + depName: 'bitfun-services-integrations', + ownerFeatures: [ + 'announcement', + 'canvas-runtime', + 'file-watch', + 'git', + 'plugin-source', + 'product-domains', + 'product-full', + 'remote-workspace', + 'review-platform', + 'ssh-remote', + ], + }, { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, { depName: 'cron', ownerFeatures: ['product-full'] }, @@ -43,7 +66,11 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'md5', ownerFeatures: ['product-full'] }, { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'product-full'] }, { depName: 'rmcp', ownerFeatures: ['product-full'] }, + { depName: 'rusqlite', ownerFeatures: ['product-full'] }, + { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['product-full'] }, + { depName: 'terminal-core', ownerFeatures: ['terminal'] }, + { depName: 'notify', ownerFeatures: ['lsp', 'workspace-watch'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['product-full'] }, { depName: 'tower-http', ownerFeatures: ['product-full'] }, { depName: 'tool-runtime', ownerFeatures: ['product-full'] }, @@ -71,12 +98,12 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', - ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], + ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], }, { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['debug-log', 'git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] }, { depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'dunce', ownerFeatures: ['plugin-source', 'remote-ssh', 'workspace-search'] }, + { depName: 'dunce', ownerFeatures: ['plugin-source', 'workspace-search'] }, { depName: 'fs2', ownerFeatures: ['plugin-source'] }, { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech'] }, @@ -139,10 +166,16 @@ export const coreProductFullFeatureAssemblyRule = { featureName: 'product-full', requiredFeatureRefs: [ 'announcement', + 'dispatch-store', 'file-watch', 'git', + 'lsp', + 'remote-workspace', 'review-platform', 'ssh-remote', + 'terminal', + 'workspace-runtime', + 'workspace-watch', 'product-capabilities', 'product-domains', 'tool-packs', @@ -151,6 +184,78 @@ export const coreProductFullFeatureAssemblyRule = { }; export const coreClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: [], + exact: true, + reason: 'services-core default profile must stay empty so consumers select capabilities explicitly', + }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'session-git', + requiredFeatureRefs: ['dep:git2'], + exact: true, + reason: 'services-core session-git must own only the libgit2-backed memory workspace capability', + }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'workspace-identity', + requiredFeatureRefs: ['dep:dunce'], + exact: true, + reason: 'services-core workspace-identity must own only canonical workspace path identity support', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'dispatch-store', + requiredFeatureRefs: [], + exact: true, + reason: 'bitfun-core dispatch-store must expose only the durable dispatch index facade', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'lsp', + requiredFeatureRefs: ['dep:notify', 'bitfun-services-core/lsp'], + exact: true, + reason: 'bitfun-core lsp must select only the LSP owner and its workspace watcher dependency', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'terminal', + requiredFeatureRefs: ['dep:terminal-core'], + exact: true, + reason: 'bitfun-core terminal must select only the standalone terminal service owner', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'workspace-runtime', + requiredFeatureRefs: [ + 'dep:serde_yaml', + 'bitfun-services-core/markdown', + 'bitfun-services-core/workspace-identity', + 'bitfun-services-core/workspace-runtime', + ], + exact: true, + reason: 'bitfun-core workspace-runtime must select only local workspace and runtime layout owners', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'workspace-watch', + requiredFeatureRefs: ['workspace-runtime', 'dep:notify'], + exact: true, + reason: 'bitfun-core workspace-watch must extend only local workspace runtime with identity watching', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'remote-workspace', + requiredFeatureRefs: [ + 'workspace-runtime', + 'dep:bitfun-services-integrations', + 'bitfun-services-integrations/remote-ssh', + ], + exact: true, + reason: 'bitfun-core remote-workspace must add only the remote workspace service surface', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'announcement', @@ -191,10 +296,14 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'ssh-remote', - requiredFeatureRefs: ['bitfun-services-integrations/remote-ssh-concrete'], + requiredFeatureRefs: [ + 'remote-workspace', + 'bitfun-services-integrations/remote-ssh-concrete', + ], + allowedTransitiveFeatureRefs: ['workspace-runtime'], exact: true, reason: - 'bitfun-core ssh-remote must select only the concrete SSH capability and must not pull product Dispatch assembly', + 'bitfun-core ssh-remote must extend only the remote workspace surface with concrete SSH and must not pull product Dispatch assembly', }, ]; diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 6f7f8de415..a050a2e2d0 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3791,9 +3791,9 @@ export const requiredContentRules = [ }, { regex: - /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", default-features = false, features = \["remote-ssh"\] \}/, + /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", default-features = false, optional = true \}/, message: - 'bitfun-services-integrations dependency may keep remote workspace identity but must not force workspace-search or product-full outside the core feature graph', + 'bitfun-services-integrations dependency must stay optional so local workspace profiles do not compile remote integrations', }, { regex: @@ -8680,9 +8680,9 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/services/services-integrations/src/remote_ssh/paths.rs', + path: 'src/crates/services/services-core/src/workspace_identity.rs', reason: - 'services-integrations remote-ssh owns workspace path/session identity helpers that do not require concrete SSH runtime handles', + 'services-core owns stable workspace path/session identity helpers without remote transport or concrete SSH runtime handles', patterns: [ { regex: /\bpub struct WorkspaceSessionIdentity\b/, @@ -9962,4 +9962,71 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/assembly/core/src/service/mod.rs', + reason: + 'bitfun-core service facades must compile only when their explicit capability profile is selected', + patterns: [ + { + regex: /#\[cfg\(feature = "dispatch-store"\)\]\s*pub mod dispatch\b/s, + message: 'dispatch store facade must stay gated behind dispatch-store', + }, + { + regex: /#\[cfg\(feature = "lsp"\)\]\s*pub mod lsp\b/s, + message: 'LSP facade must stay gated behind lsp', + }, + { + regex: /#\[cfg\(feature = "remote-workspace"\)\]\s*pub mod remote_ssh\b/s, + message: 'remote workspace facade must stay gated behind remote-workspace', + }, + { + regex: /#\[cfg\(feature = "workspace-runtime"\)\]\s*pub mod workspace\b/s, + message: 'workspace facade must stay gated behind workspace-runtime', + }, + { + regex: /#\[cfg\(feature = "terminal"\)\]\s*pub use terminal_core as terminal\b/s, + message: 'terminal compatibility export must stay gated behind terminal', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/session/mod.rs', + reason: 'libgit2-backed memory workspace behavior must remain isolated from the reusable session profile', + patterns: [ + { + regex: /#\[cfg\(feature = "session-git"\)\]\s*mod memory_workspace\b/s, + message: 'memory workspace implementation must stay gated behind session-git', + }, + { + regex: /#\[cfg\(feature = "session-git"\)\]\s*pub use memory_workspace\b/s, + message: 'memory workspace exports must stay gated behind session-git', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/remote_ssh/paths.rs', + reason: + 'remote SSH must preserve its public path while delegating stable workspace identity to services-core', + patterns: [ + { + regex: /pub use bitfun_services_core::workspace_identity::\*/, + message: 'remote SSH path compatibility module must re-export the services-core owner', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/workspace/service.rs', + reason: + 'local workspace profiles must use stable service-owned identity and fail closed for unavailable remote runtime behavior', + patterns: [ + { + regex: /use bitfun_services_core::workspace_identity::\{/, + message: 'workspace service must consume the services-core identity owner directly', + }, + { + regex: /Remote workspace support is not compiled into this product profile/, + message: 'workspace service must report an explicit unsupported state without remote-workspace', + }, + ], + }, ]; diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index d32b4d97f7..d42b312630 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -146,10 +146,16 @@ export function runManifestParserSelfTest({ for (const featureName of [ 'announcement', + 'dispatch-store', 'file-watch', 'git', + 'lsp', + 'remote-workspace', 'review-platform', 'ssh-remote', + 'terminal', + 'workspace-runtime', + 'workspace-watch', 'product-capabilities', 'product-domains', 'tool-packs', @@ -158,19 +164,55 @@ export function runManifestParserSelfTest({ throw new Error(`core product-full assembly rule must require ${featureName}`); } } + const closedProfileKey = (manifestPath, featureName) => `${manifestPath}:${featureName}`; const closedCoreProfiles = new Map( - coreClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + coreClosedFeatureProfileRules.map((rule) => [ + closedProfileKey(rule.manifestPath, rule.featureName), + rule, + ]), ); - const expectedClosedCoreProfiles = new Map([ - ['announcement', ['bitfun-services-integrations/announcement']], - ['file-watch', ['bitfun-services-integrations/file-watch']], - ['git', ['bitfun-services-integrations/git']], - ['review-platform', ['bitfun-services-integrations/review-platform']], - ['service-integrations', ['announcement', 'file-watch', 'git', 'review-platform']], - ['ssh-remote', ['bitfun-services-integrations/remote-ssh-concrete']], - ]); - for (const [featureName, expectedReferences] of expectedClosedCoreProfiles) { - const rule = closedCoreProfiles.get(featureName); + const coreManifest = 'src/crates/assembly/core/Cargo.toml'; + const servicesCoreManifest = 'src/crates/services/services-core/Cargo.toml'; + const expectedClosedCoreProfiles = [ + [servicesCoreManifest, 'default', []], + [servicesCoreManifest, 'session-git', ['dep:git2']], + [servicesCoreManifest, 'workspace-identity', ['dep:dunce']], + [coreManifest, 'dispatch-store', []], + [coreManifest, 'lsp', ['dep:notify', 'bitfun-services-core/lsp']], + [coreManifest, 'terminal', ['dep:terminal-core']], + [ + coreManifest, + 'workspace-runtime', + [ + 'dep:serde_yaml', + 'bitfun-services-core/markdown', + 'bitfun-services-core/workspace-identity', + 'bitfun-services-core/workspace-runtime', + ], + ], + [coreManifest, 'workspace-watch', ['workspace-runtime', 'dep:notify']], + [ + coreManifest, + 'remote-workspace', + [ + 'workspace-runtime', + 'dep:bitfun-services-integrations', + 'bitfun-services-integrations/remote-ssh', + ], + ], + [coreManifest, 'announcement', ['bitfun-services-integrations/announcement']], + [coreManifest, 'file-watch', ['bitfun-services-integrations/file-watch']], + [coreManifest, 'git', ['bitfun-services-integrations/git']], + [coreManifest, 'review-platform', ['bitfun-services-integrations/review-platform']], + [coreManifest, 'service-integrations', ['announcement', 'file-watch', 'git', 'review-platform']], + [ + coreManifest, + 'ssh-remote', + ['remote-workspace', 'bitfun-services-integrations/remote-ssh-concrete'], + ], + ]; + for (const [manifestPath, featureName, expectedReferences] of expectedClosedCoreProfiles) { + const rule = closedCoreProfiles.get(closedProfileKey(manifestPath, featureName)); if (!rule?.exact) { throw new Error(`core closed feature profile must cover ${featureName} exactly`); } @@ -183,6 +225,12 @@ export function runManifestParserSelfTest({ if (rule.requiredFeatureRefs.some((reference) => reference.includes('product-full'))) { throw new Error(`core closed feature profile must not hide product-full in ${featureName}`); } + if ( + (rule.allowedTransitiveFeatureRefs ?? []) + .some((reference) => reference.includes('product-full')) + ) { + throw new Error(`core closed feature profile must not reach product-full in ${featureName}`); + } } const ownerFeatureRulePaths = new Set( ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), @@ -223,7 +271,14 @@ export function runManifestParserSelfTest({ 'git = ["bitfun-services-integrations/git"]', 'review-platform = ["bitfun-services-integrations/review-platform"]', 'service-integrations = ["announcement", "file-watch", "git", "review-platform"]', + 'workspace-runtime = ["dep:serde_yaml", "bitfun-services-core/workspace-runtime"]', + 'remote-workspace = [', + ' "workspace-runtime",', + ' "dep:bitfun-services-integrations",', + ' "bitfun-services-integrations/remote-ssh",', + ']', 'ssh-remote = [', + ' "remote-workspace",', ' "bitfun-services-integrations/remote-ssh-concrete",', ']', '[dependencies]', @@ -245,6 +300,9 @@ export function runManifestParserSelfTest({ ) { throw new Error('feature parser must detect dependency capability feature references'); } + if (!parsedFeatures.get('ssh-remote')?.refs.includes('remote-workspace')) { + throw new Error('feature parser must detect local capability feature references'); + } const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); const facadePositiveCases = [ @@ -655,11 +713,28 @@ export function runManifestParserSelfTest({ const servicesCoreDunceOwner = servicesCoreOptionalOwnerRule?.dependencies.find( (dependency) => dependency.depName === 'dunce', ); - for (const feature of ['runtime-ownership', 'workspace-runtime']) { + for (const feature of ['runtime-ownership', 'workspace-identity', 'workspace-runtime']) { if (!servicesCoreDunceOwner?.ownerFeatures.includes(feature)) { throw new Error(`services-core ${feature} must own optional dependency dunce`); } } + const expectedServicesCoreOwners = new Map([ + ['git2', ['session-git']], + ['notify', ['lsp']], + ['rusqlite', ['permission']], + ['serde_yaml', ['markdown']], + ['zip', ['lsp']], + ]); + for (const [dependencyName, ownerFeatures] of expectedServicesCoreOwners) { + const dependency = servicesCoreOptionalOwnerRule?.dependencies.find( + (candidate) => candidate.depName === dependencyName, + ); + for (const featureName of ownerFeatures) { + if (!dependency?.ownerFeatures.includes(featureName)) { + throw new Error(`services-core ${featureName} must own optional dependency ${dependencyName}`); + } + } + } const servicesOptionalOwnerDeps = new Set( servicesOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], ); @@ -3814,7 +3889,7 @@ export function runManifestParserSelfTest({ 'bitfun-product-capabilities = \\{ path = "\\.\\.\\/product-capabilities", default-features = false, optional = true \\}', 'bitfun-ai-adapters = \\{ path = "\\.\\.\\/\\.\\.\\/adapters\\/ai-adapters", optional = true \\}', 'bitfun-tool-packs = \\{ path = "\\.\\.\\/\\.\\.\\/execution\\/tool-provider-groups", default-features = false, optional = true \\}', - 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", default-features = false, features = \\["remote-ssh"\\] \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", default-features = false, optional = true \\}', 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", default-features = false, optional = true \\}', 'dep:bitfun-ai-adapters', 'ai-adapter-runtime', @@ -4181,7 +4256,7 @@ export function runManifestParserSelfTest({ contracts: ['RemoteTerminalManager', 'PtyCommand', 'channel.window_change'], }, { - path: 'src/crates/services/services-integrations/src/remote_ssh/paths.rs', + path: 'src/crates/services/services-core/src/workspace_identity.rs', contracts: [ 'WorkspaceSessionIdentity', 'workspace_session_identity', diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index 6d47c35959..a4eac10737 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -50,6 +50,13 @@ SessionManager -> Session -> DialogTurn -> ModelRound - Remote/service 改动必须保持 external protocol lifecycle、workspace projection、scheduler/session restore、 terminal pre-warm 和 product execution 边界清晰。 - Feature 改动必须保持 `product-full` 作为兼容产品组装边界;默认能力选择只有在单独的 product matrix review 后才能变化。 +- 保持轻量兼容 feature 可独立编译。本地服务 profile 为 `dispatch-store`、`lsp`、`terminal`、 + `workspace-runtime` 和 `workspace-watch`;`remote-workspace` 只增加远程工作区 facade, + `ssh-remote` 才增加具体 SSH transport。`announcement`、`file-watch`、`git`、 + `review-platform` 也保持独立,`service-integrations` 只是其兼容聚合。任何窄 feature 都不得直接或间接启用 `product-full`。 +- `product-full` 必须显式组合自身消费的每个能力,包括 `permission`、`session-git`、 + `runtime-ownership` 等产品专属 `services-core` feature。不得把这些 feature 写在依赖声明上, + 否则 Cargo feature union 会迫使所有 core consumer 编译它们。 - 保持 `cargo check -p bitfun-core --no-default-features` 可用。产品专属模块必须由 owner feature 控制;轻量 facade 操作在缺少产品 owner 时若无法安全完成,应明确 fail-closed 并保留持久化恢复状态,不得隐式启用 `product-full`。 @@ -81,6 +88,9 @@ SessionManager -> Session -> DialogTurn -> ModelRound ```bash cargo check --workspace cargo check -p bitfun-core --no-default-features +cargo check -p bitfun-core --no-default-features --features workspace-runtime +cargo check -p bitfun-core --no-default-features --features remote-workspace +cargo check -p bitfun-core --no-default-features --features ssh-remote cargo test -p bitfun-core --lib -- --nocapture node scripts/check-core-boundaries.mjs ``` diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index a5298c4a55..225e154b4a 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -79,12 +79,17 @@ SessionManager -> Session -> DialogTurn -> ModelRound - Feature work must keep `product-full` as the compatibility product assembly boundary unless a separate product matrix review changes default capability selection. -- Keep the light compatibility features independently compilable: - `announcement`, `file-watch`, `git`, and `review-platform` own their matching - service facade, while `service-integrations` is only their compatibility - aggregate. `ssh-remote` selects the concrete SSH service only; Dispatch - controllers, MCP, Remote Connect, and agent runtime wiring remain - `product-full` product composition. +- Keep the light compatibility features independently compilable. Local service + profiles are `dispatch-store`, `lsp`, `terminal`, `workspace-runtime`, and + `workspace-watch`; `remote-workspace` adds only the remote workspace facade, + while `ssh-remote` adds concrete SSH transport. Integration facades + `announcement`, `file-watch`, `git`, and `review-platform` remain independent, + with `service-integrations` only their compatibility aggregate. None of these + narrow features may enable `product-full` directly or transitively. +- `product-full` must explicitly compose every capability it consumes, including + product-only `services-core` features such as `permission`, `session-git`, and + `runtime-ownership`. Do not put those features on the dependency declaration, + because Cargo feature union would force them into every core consumer. - Keep `cargo check -p bitfun-core --no-default-features` viable. Gate product-only modules at their owner feature; if a light facade operation cannot safely complete without a product owner, fail closed and preserve any @@ -118,6 +123,9 @@ Use the smallest check that matches the touched behavior: ```bash cargo check --workspace cargo check -p bitfun-core --no-default-features +cargo check -p bitfun-core --no-default-features --features workspace-runtime +cargo check -p bitfun-core --no-default-features --features remote-workspace +cargo check -p bitfun-core --no-default-features --features ssh-remote cargo test -p bitfun-core --lib -- --nocapture node scripts/check-core-boundaries.mjs ``` diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 161512c351..52b5db851c 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -19,7 +19,7 @@ futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } +serde_yaml = { workspace = true, optional = true } anyhow = { workspace = true } thiserror = { workspace = true } @@ -35,7 +35,7 @@ base64 = { workspace = true } image = { workspace = true, optional = true } md5 = { workspace = true, optional = true } hex = { workspace = true } -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.32", features = ["bundled"], optional = true } dashmap = { workspace = true, optional = true } indexmap = { workspace = true, optional = true } @@ -45,7 +45,7 @@ reqwest = { workspace = true, optional = true } axum = { workspace = true, optional = true } tower-http = { workspace = true, optional = true } -notify = { workspace = true } +notify = { workspace = true, optional = true } dirs = { workspace = true } dunce = { workspace = true } filetime = { workspace = true, optional = true } @@ -86,17 +86,10 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../../services/services-core", default-features = false, features = [ - "dispatch-workspace", - "lsp", - "markdown", - "permission", - "runtime-ownership", - "workspace-runtime", -] } +bitfun-services-core = { path = "../../services/services-core", default-features = false } # Integration service owner crate -bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["remote-ssh"] } +bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, optional = true } # Product domain owner crate bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, optional = true } @@ -107,7 +100,7 @@ tool-runtime = { path = "../../execution/tool-execution", default-features = fal ] } # terminal -terminal-core = { path = "../../services/terminal" } +terminal-core = { path = "../../services/terminal", optional = true } # I18n internationalization fluent-bundle = { workspace = true } @@ -162,12 +155,23 @@ product-full = [ "dep:image", "dep:md5", "dep:reqwest", + "dep:rusqlite", "dep:rmcp", "dep:similar", "dep:tokio-tungstenite", "dep:tower-http", "dep:tool-runtime", "bitfun-services-integrations/product-full", + "bitfun-services-core/dispatch-workspace", + "bitfun-services-core/permission", + "bitfun-services-core/runtime-ownership", + "bitfun-services-core/session-git", + "dispatch-store", + "lsp", + "remote-workspace", + "terminal", + "workspace-runtime", + "workspace-watch", "ssh-remote", "product-capabilities", "product-domains", @@ -207,11 +211,27 @@ file-watch = ["bitfun-services-integrations/file-watch"] git = ["bitfun-services-integrations/git"] review-platform = ["bitfun-services-integrations/review-platform"] service-integrations = ["announcement", "file-watch", "git", "review-platform"] +dispatch-store = [] +lsp = ["dep:notify", "bitfun-services-core/lsp"] +terminal = ["dep:terminal-core"] +workspace-runtime = [ + "dep:serde_yaml", + "bitfun-services-core/markdown", + "bitfun-services-core/workspace-identity", + "bitfun-services-core/workspace-runtime", +] +workspace-watch = ["workspace-runtime", "dep:notify"] +remote-workspace = [ + "workspace-runtime", + "dep:bitfun-services-integrations", + "bitfun-services-integrations/remote-ssh", +] tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full", "dep:image"] # Deprecated compatibility feature. Tauri integration is desktop-owned now; # keep the public feature name as a no-op for downstream manifests. tauri-support = [] ssh-remote = [ + "remote-workspace", "bitfun-services-integrations/remote-ssh-concrete", ] diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 748d845fe7..1f1ddc2bcc 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -65,10 +65,9 @@ pub use util::errors::*; pub use util::types::*; // Export service layer components -pub use service::{ - config::{ConfigManager, ConfigService}, - workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}, -}; +pub use service::config::{ConfigManager, ConfigService}; +#[cfg(feature = "workspace-runtime")] +pub use service::workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}; // Export infrastructure components #[cfg(feature = "ai-adapter-runtime")] diff --git a/src/crates/assembly/core/src/service/filesystem/service.rs b/src/crates/assembly/core/src/service/filesystem/service.rs index bdae1ec33b..284dce3007 100644 --- a/src/crates/assembly/core/src/service/filesystem/service.rs +++ b/src/crates/assembly/core/src/service/filesystem/service.rs @@ -18,6 +18,7 @@ fn map_filesystem_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(error.to_string()) } +#[cfg(feature = "remote-workspace")] async fn read_remote_directory_contents( path: &str, preferred_remote_connection_id: Option<&str>, @@ -28,8 +29,17 @@ async fn read_remote_directory_contents( ) .await?; - let manager = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager()?; - let file_service = manager.get_file_service().await?; + let Some(manager) = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() + else { + return Some(Err(BitFunError::service( + "Remote workspace manager is unavailable", + ))); + }; + let Some(file_service) = manager.get_file_service().await else { + return Some(Err(BitFunError::service( + "Remote file service is unavailable", + ))); + }; Some( match file_service.read_dir(&entry.connection_id, path).await { @@ -53,6 +63,24 @@ async fn read_remote_directory_contents( ) } +#[cfg(not(feature = "remote-workspace"))] +async fn read_remote_directory_contents( + _path: &str, + _preferred_remote_connection_id: Option<&str>, +) -> Option>> { + None +} + +#[cfg(feature = "remote-workspace")] +async fn is_remote_path(path: &str) -> bool { + crate::service::remote_ssh::workspace_state::is_remote_path(path).await +} + +#[cfg(not(feature = "remote-workspace"))] +async fn is_remote_path(_path: &str) -> bool { + false +} + /// Unified file system service pub struct FileSystemService { inner: BaseFileSystemService, @@ -84,7 +112,7 @@ impl FileSystemService { preferred_remote_connection_id: Option<&str>, ) -> BitFunResult> { let started_at = std::time::Instant::now(); - let tree = if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { + let tree = if is_remote_path(root_path).await { self.get_directory_contents_with_remote_hint(root_path, preferred_remote_connection_id) .await? } else { @@ -112,30 +140,29 @@ impl FileSystemService { pub async fn scan_directory(&self, root_path: &str) -> BitFunResult { let start_time = std::time::Instant::now(); - let (files, statistics) = - if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { - let nodes = self - .get_directory_contents_with_remote_hint(root_path, None) - .await?; - let stats = FileTreeStatistics { - total_files: nodes.iter().filter(|node| !node.is_directory).count(), - total_directories: nodes.iter().filter(|node| node.is_directory).count(), - total_size_bytes: 0, - max_depth_reached: 0, - file_type_counts: HashMap::new(), - large_files: Vec::new(), - symlinks_count: 0, - hidden_files_count: 0, - }; - (nodes, stats) - } else { - let scan_result = self - .inner - .scan_directory(root_path) - .await - .map_err(map_filesystem_error)?; - (scan_result.files, scan_result.statistics) + let (files, statistics) = if is_remote_path(root_path).await { + let nodes = self + .get_directory_contents_with_remote_hint(root_path, None) + .await?; + let stats = FileTreeStatistics { + total_files: nodes.iter().filter(|node| !node.is_directory).count(), + total_directories: nodes.iter().filter(|node| node.is_directory).count(), + total_size_bytes: 0, + max_depth_reached: 0, + file_type_counts: HashMap::new(), + large_files: Vec::new(), + symlinks_count: 0, + hidden_files_count: 0, }; + (nodes, stats) + } else { + let scan_result = self + .inner + .scan_directory(root_path) + .await + .map_err(map_filesystem_error)?; + (scan_result.files, scan_result.statistics) + }; let scan_time_ms = elapsed_ms_u64(start_time); @@ -455,3 +482,40 @@ impl FileSystemService { self.inner.editor_sync_sha256_hex_from_raw_bytes(bytes) } } + +#[cfg(all(test, feature = "remote-workspace", not(feature = "ssh-remote")))] +mod tests { + use super::FileSystemService; + use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; + + #[tokio::test] + async fn registered_remote_path_without_file_provider_fails_closed() { + let temp = tempfile::tempdir().expect("tempdir"); + let remote_root = temp.path().to_string_lossy().to_string(); + let connection_id = "filesystem-no-provider"; + let manager = init_remote_workspace_manager(); + manager + .register_remote_workspace( + remote_root.clone(), + connection_id.to_string(), + "No provider".to_string(), + "no-provider-host".to_string(), + ) + .await; + + let error = FileSystemService::default() + .get_directory_contents_with_remote_hint(&remote_root, Some(connection_id)) + .await + .expect_err("registered remote paths must not fall back to the local filesystem"); + + manager + .unregister_remote_workspace(connection_id, &remote_root) + .await; + assert!( + error + .to_string() + .contains("Remote file service is unavailable"), + "unexpected error: {error}" + ); + } +} diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index 038fd46898..8e9235627a 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -6,12 +6,14 @@ #[cfg(feature = "announcement")] pub mod announcement; // Announcement / feature-demo / tips system +#[cfg(feature = "workspace-runtime")] pub(crate) mod bootstrap; // Workspace persona bootstrap helpers #[cfg(feature = "canvas-runtime")] pub mod canvas; // Canvas service compatibility facade pub mod config; // Config management #[cfg(feature = "product-full")] pub mod cron; // Scheduled jobs +#[cfg(feature = "dispatch-store")] pub mod dispatch; // Outbound dispatch observer index and target contracts pub mod filesystem; // FileSystem management #[cfg(feature = "git")] @@ -19,11 +21,13 @@ pub mod git; // Git service pub mod i18n; // I18n service #[cfg(feature = "product-full")] pub(crate) mod instruction_context; // Workspace instruction file prompt helpers +#[cfg(feature = "lsp")] pub mod lsp; // LSP (Language Server Protocol) system #[cfg(feature = "product-full")] pub mod mcp; // MCP (Model Context Protocol) system #[cfg(feature = "product-full")] pub mod remote_connect; // Remote Connect (phone → desktop) +#[cfg(feature = "remote-workspace")] pub mod remote_ssh; // Remote SSH (desktop → server) #[cfg(feature = "review-platform")] pub mod review_platform; // Pull request review platform adapters @@ -37,13 +41,16 @@ pub mod session_usage; // Session runtime usage reports pub mod snapshot; // Snapshot-based change tracking #[cfg(feature = "product-full")] pub mod token_usage; // Token usage tracking +#[cfg(feature = "workspace-runtime")] pub mod workspace; // Workspace management // Diff calculation and merge service +#[cfg(feature = "workspace-runtime")] pub mod workspace_runtime; // Workspace runtime layout / migration / initialization #[cfg(feature = "product-full")] pub mod worktree; // Managed Git worktree lifecycle and session bindings // Terminal is implemented in the workspace-level `terminal-core` crate. // This re-export preserves the legacy `bitfun_core::service::terminal` path. +#[cfg(feature = "terminal")] pub use terminal_core as terminal; // Re-export main components. @@ -52,6 +59,7 @@ pub use announcement::{AnnouncementCard, AnnouncementScheduler, AnnouncementSche pub use bitfun_services_core::{diagnostics, diff, system}; #[cfg(feature = "file-watch")] pub use bitfun_services_integrations::file_watch; +#[cfg(feature = "workspace-runtime")] pub use bootstrap::reset_workspace_persona_files_to_default; #[cfg(feature = "canvas-runtime")] pub use canvas::{CanvasMemoryStore, CanvasService}; @@ -73,6 +81,7 @@ pub use filesystem::{DirectoryStats, FileSystemService, FileSystemServiceFactory #[cfg(feature = "git")] pub use git::GitService; pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, LocaleMetadata}; +#[cfg(feature = "lsp")] pub use lsp::LspManager; #[cfg(feature = "product-full")] pub use mcp::MCPService; @@ -110,7 +119,9 @@ pub use token_usage::{ ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, TokenUsageService, TokenUsageSummary, }; +#[cfg(feature = "workspace-runtime")] pub use workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}; +#[cfg(feature = "workspace-runtime")] pub use workspace_runtime::{ get_workspace_runtime_service_arc, try_get_workspace_runtime_service_arc, RuntimeMigrationRecord, WorkspaceRuntimeContext, WorkspaceRuntimeEnsureResult, diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index 58cecdecc9..8a536c6ae5 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -3,12 +3,12 @@ #[cfg(feature = "git")] use super::worktree_topology::global_worktree_topology_service; use super::WorktreeTopologyFreshness; -use crate::service::remote_ssh::workspace_state::{ +use crate::util::{errors::*, FrontMatterMarkdown}; +use bitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, local_workspace_roots_equal, local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, normalize_remote_workspace_path, LOCAL_WORKSPACE_SSH_HOST, }; -use crate::util::{errors::*, FrontMatterMarkdown}; use log::{info, warn}; use serde::{Deserialize, Serialize}; diff --git a/src/crates/assembly/core/src/service/workspace/mod.rs b/src/crates/assembly/core/src/service/workspace/mod.rs index aeb4b6cfda..e90837e730 100644 --- a/src/crates/assembly/core/src/service/workspace/mod.rs +++ b/src/crates/assembly/core/src/service/workspace/mod.rs @@ -3,6 +3,7 @@ //! Full workspace management system: open, manage, scan, statistics, etc. pub mod factory; +#[cfg(feature = "workspace-watch")] pub mod identity_watch; pub mod manager; pub mod provider; @@ -18,6 +19,7 @@ pub enum WorktreeTopologyFreshness { // Re-export main components pub use factory::WorkspaceFactory; +#[cfg(feature = "workspace-watch")] pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ GitInfo, RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index e335cb7f92..4c46616f4a 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -15,14 +15,18 @@ use crate::service::bootstrap::{ }; #[cfg(feature = "git")] use crate::service::git::{GitError, GitWorktreeInfo}; +#[cfg(feature = "remote-workspace")] use crate::service::remote_ssh::workspace_state::{ - canonicalize_local_workspace_root, get_remote_workspace_manager, init_remote_workspace_manager, - local_workspace_roots_equal, normalize_remote_workspace_path, remote_workspace_stable_id, + get_remote_workspace_manager, init_remote_workspace_manager, }; use crate::service::workspace_runtime::{ try_get_workspace_runtime_service_arc, WorkspaceRuntimeService, }; use crate::util::errors::*; +use bitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, local_workspace_roots_equal, + normalize_remote_workspace_path, remote_workspace_stable_id, +}; use log::{info, warn}; use serde::{Deserialize, Serialize}; @@ -384,6 +388,12 @@ impl WorkspaceService { options: WorkspaceCreateOptions, ) -> BitFunResult { let options = self.normalize_workspace_options_for_path(&path, options); + #[cfg(not(feature = "remote-workspace"))] + if options.workspace_kind == WorkspaceKind::Remote { + return Err(BitFunError::service( + "Remote workspace support is not compiled into this product profile", + )); + } let worktree = WorkspaceInfo::resolve_worktree_info(&path, WorktreeTopologyFreshness::Cached).await; let result = { @@ -402,6 +412,7 @@ impl WorkspaceService { .await; self.ensure_workspace_runtime_best_effort(workspace, "opened") .await; + #[cfg(feature = "remote-workspace")] if workspace.workspace_kind == WorkspaceKind::Remote { self.register_remote_workspace_runtime(workspace).await; } @@ -528,6 +539,7 @@ impl WorkspaceService { Ok(opened) } + #[cfg(feature = "remote-workspace")] async fn register_remote_workspace_runtime(&self, workspace: &WorkspaceInfo) { let Some(connection_id) = workspace.remote_ssh_connection_id() else { warn!( @@ -894,7 +906,6 @@ impl WorkspaceService { connection_id: &str, remote_workspace_path: &str, ) -> Option { - use crate::service::remote_ssh::normalize_remote_workspace_path; let cid = connection_id.trim(); if cid.is_empty() { return None; @@ -1277,6 +1288,7 @@ impl WorkspaceService { let mut seen_paths = HashSet::new(); match workspace.workspace_kind { + #[cfg(feature = "remote-workspace")] WorkspaceKind::Remote => { let connection_id = workspace .remote_ssh_connection_id() @@ -1348,6 +1360,12 @@ impl WorkspaceService { normalized.push(RelatedPath { path, description }); } } + #[cfg(not(feature = "remote-workspace"))] + WorkspaceKind::Remote => { + return Err(BitFunError::service( + "Remote workspace related paths require the remote-workspace feature", + )); + } _ => { for related_path in related_paths { let description = diff --git a/src/crates/assembly/core/src/service/workspace_runtime/service.rs b/src/crates/assembly/core/src/service/workspace_runtime/service.rs index d4c15e8314..d27df10847 100644 --- a/src/crates/assembly/core/src/service/workspace_runtime/service.rs +++ b/src/crates/assembly/core/src/service/workspace_runtime/service.rs @@ -5,15 +5,15 @@ use super::types::{ #[cfg(feature = "product-full")] use crate::agentic::WorkspaceBinding; use crate::infrastructure::{get_path_manager_arc, PathManager}; -use crate::service::remote_ssh::workspace_state::{ - normalize_remote_workspace_path, remote_root_to_mirror_subpath, - sanitize_ssh_hostname_for_mirror, -}; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_services_core::session::{ merge_legacy_session_store, move_legacy_path, SessionStoreMigrationError, SessionStoreMigrationRecord, }; +use bitfun_services_core::workspace_identity::{ + normalize_remote_workspace_path, remote_root_to_mirror_subpath, + sanitize_ssh_hostname_for_mirror, +}; use log::debug; use serde::Serialize; use std::collections::{HashMap, HashSet}; diff --git a/src/crates/assembly/core/src/util/mod.rs b/src/crates/assembly/core/src/util/mod.rs index 8f3023895b..f7de39284c 100644 --- a/src/crates/assembly/core/src/util/mod.rs +++ b/src/crates/assembly/core/src/util/mod.rs @@ -1,6 +1,7 @@ //! Common utilities and type definitions pub mod errors; +#[cfg(feature = "workspace-runtime")] pub mod front_matter_markdown; pub mod json_extract; pub mod plain_output; @@ -10,6 +11,7 @@ pub mod token_counter; pub mod types; pub use errors::*; +#[cfg(feature = "workspace-runtime")] pub use front_matter_markdown::FrontMatterMarkdown; pub use json_extract::extract_json_from_ai_response; pub use plain_output::sanitize_plain_model_output; diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 8c909066bc..1aa70dbcdf 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -17,8 +17,11 @@ crate. runtime crates. - Prefer `bitfun-core-types` for shared DTOs and `bitfun-runtime-ports` for cross-layer traits. -- Keep dependency features explicit. Non-LSP consumers should use - `default-features = false`; LSP consumers must enable the `lsp` feature. +- Keep dependency features explicit and keep `default = []`. Consumers enable + `lsp`, `workspace-runtime`, `workspace-identity`, `runtime-ownership`, + `permission`, `dispatch-workspace`, `markdown`, or `session-git` only for the + owner behavior they use. In particular, session metadata consumers must not + compile libgit2 unless they use the memory-workspace baseline/diff API. - LSP manifest and protocol DTOs belong in `bitfun-core-types`; reusable LSP package, protocol, detection, debounce, watch, and process-manager helpers belong in `services-core`; product workspace state, event emission, global @@ -30,6 +33,10 @@ crate. - `runtime_ownership` owns only canonical identity plus Embedded shared-lock and Shared exclusive-lock primitives. It must not select workspaces, start or cache Runtime instances, or define Session/Turn ownership. +- `workspace_identity` owns canonical local roots plus stable local/remote + workspace and session-storage identifiers. It has no SSH registry, transport, + authentication, SFTP, PTY, or remote lifecycle responsibility; integrations + may preserve old paths through re-exports. - Do not add remote SSH, MiniApp storage, tool-result persistence, `PathManager` globals, or product runtime bindings to `filesystem`; keep those in core or a reviewed adapter/provider. @@ -47,6 +54,8 @@ crate. ```bash cargo test -p bitfun-services-core --features lsp +cargo test -p bitfun-services-core --no-default-features --features session-git memory_workspace +cargo check -p bitfun-services-core --no-default-features --features workspace-identity cargo test -p bitfun-services-core --features workspace-runtime workspace cargo test -p bitfun-services-core --features runtime-ownership --test runtime_ownership_contracts node scripts/check-core-boundaries.mjs diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 6681b52132..6574172350 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -21,7 +21,7 @@ serde_json = { workspace = true } serde_yaml = { workspace = true, optional = true } base64 = { workspace = true } chrono = { workspace = true } -git2 = { workspace = true } +git2 = { workspace = true, optional = true } dunce = { workspace = true, optional = true } zip = { workspace = true, optional = true } thiserror = { workspace = true } @@ -47,19 +47,21 @@ windows = { workspace = true, features = [ # Keep libgit2 self-contained on Unix, matching the product assembly dependency. [target.'cfg(not(windows))'.dependencies] -git2 = { workspace = true, features = ["vendored-openssl"] } +git2 = { workspace = true, features = ["vendored-openssl"], optional = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } [features] -default = ["lsp"] +default = [] lsp = ["dep:anyhow", "dep:notify", "dep:zip"] markdown = ["dep:serde_yaml"] workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports", "dep:dunce"] +workspace-identity = ["dep:dunce"] runtime-ownership = ["dep:dunce"] permission = ["dep:async-trait", "dep:bitfun-runtime-ports", "dep:rusqlite", "bitfun-runtime-ports/permission"] dispatch-workspace = ["dep:anyhow"] +session-git = ["dep:git2"] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 6f965d23a4..170af05bac 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -35,5 +35,7 @@ pub mod system; pub mod token_usage; #[cfg(feature = "workspace-runtime")] pub mod workspace; +#[cfg(feature = "workspace-identity")] +pub mod workspace_identity; pub mod workspace_instructions; pub mod workspace_text; diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index f3bf057ab2..1ac22c4b14 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -1,5 +1,6 @@ pub mod layout; mod lineage; +#[cfg(feature = "session-git")] mod memory_workspace; mod metadata; mod metadata_store; @@ -15,6 +16,7 @@ pub use lineage::{ format_branch_session_name, resolve_branch_session_lineage, BranchSessionLineage, BranchSessionMetadataFacts, SessionBranchBoundary, SessionBranchRequest, SessionBranchResult, }; +#[cfg(feature = "session-git")] pub use memory_workspace::{ ensure_memory_workspace_git_baseline, memory_workspace_diff, render_memory_workspace_diff_file, reset_memory_workspace_git_baseline, MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, diff --git a/src/crates/services/services-core/src/workspace_identity.rs b/src/crates/services/services-core/src/workspace_identity.rs new file mode 100644 index 0000000000..782422b207 --- /dev/null +++ b/src/crates/services/services-core/src/workspace_identity.rs @@ -0,0 +1,325 @@ +//! Remote SSH workspace path and identity helpers. + +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// SSH host label for local disk workspaces (`Normal` / `Assistant`). +pub const LOCAL_WORKSPACE_SSH_HOST: &str = "localhost"; + +/// Unified workspace identity used to resolve session persistence for local and remote workspaces. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceSessionIdentity { + pub hostname: String, + /// Canonical local root or normalized remote root used to identify the logical workspace. + pub logical_workspace_path: String, + pub remote_connection_id: Option, +} + +impl WorkspaceSessionIdentity { + pub fn is_remote(&self) -> bool { + self.hostname != LOCAL_WORKSPACE_SSH_HOST + } + + pub fn logical_workspace_path(&self) -> &str { + &self.logical_workspace_path + } +} + +/// Normalize a remote POSIX workspace path for registry lookup on any client OS. +pub fn normalize_remote_workspace_path(path: &str) -> String { + let mut s = path.replace('\\', "/"); + while s.contains("//") { + s = s.replace("//", "/"); + } + if s == "/" { + return s; + } + s.trim_end_matches('/').to_string() +} + +/// Connection id as one safe local path component. +pub fn sanitize_ssh_connection_id_for_local_dir(connection_id: &str) -> String { + if connection_id == "." { + return "_dot_".to_string(); + } + if connection_id == ".." { + return "_dotdot_".to_string(); + } + #[cfg(windows)] + { + sanitize_windows_path_component(connection_id) + } + #[cfg(not(windows))] + { + connection_id + .chars() + .map(|character| { + if character == '/' || character == '\0' { + '-' + } else { + character + } + }) + .collect() + } +} + +/// Sanitize a single path component for the local remote-workspace mirror tree. +pub fn sanitize_remote_mirror_path_component(component: &str) -> String { + let t = component.trim(); + if t.is_empty() { + return "_".to_string(); + } + if t == "." { + return "_dot_".to_string(); + } + if t == ".." { + return "_dotdot_".to_string(); + } + #[cfg(windows)] + { + sanitize_windows_path_component(t) + } + #[cfg(not(windows))] + { + t.chars() + .map(|c| if c == '/' || c == '\0' { '-' } else { c }) + .collect() + } +} + +#[cfg(windows)] +fn sanitize_windows_path_component(component: &str) -> String { + let mut sanitized: String = component + .chars() + .map(|c| match c { + '<' | '>' | '"' | ':' | '/' | '\\' | '|' | '?' | '*' => '-', + c if c.is_control() => '-', + _ => c, + }) + .collect(); + while sanitized.ends_with('.') || sanitized.ends_with(' ') { + sanitized.pop(); + } + if sanitized.is_empty() { + return "_".to_string(); + } + + let stem = sanitized + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved = matches!( + stem.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ); + if reserved { + sanitized.insert(0, '_'); + } + sanitized +} + +/// SSH host or alias as a single directory name under `remote_ssh/`. +pub fn sanitize_ssh_hostname_for_mirror(host: &str) -> String { + sanitize_remote_mirror_path_component(&host.trim().to_lowercase()) +} + +/// Map normalized remote workspace root to path segments under the host directory. +pub fn remote_root_to_mirror_subpath(remote_root_norm: &str) -> PathBuf { + let mut pb = PathBuf::new(); + if remote_root_norm == "/" { + pb.push("_root"); + return pb; + } + for seg in remote_root_norm.trim_start_matches('/').split('/') { + if seg.is_empty() { + continue; + } + if seg == "." { + continue; + } + if seg == ".." { + // Match the effective local path produced by the legacy + // `PathBuf::push("..")` mapping without allowing the result to + // escape the host mirror root. + pb.pop(); + continue; + } + pb.push(sanitize_remote_mirror_path_component(seg)); + } + if pb.as_os_str().is_empty() { + pb.push("_root"); + } + pb +} + +/// Local runtime root for a registered remote workspace. +pub fn remote_workspace_runtime_root( + remote_mirror_root: impl AsRef, + ssh_host: &str, + remote_root_norm: &str, +) -> PathBuf { + remote_mirror_root + .as_ref() + .join(sanitize_ssh_hostname_for_mirror(ssh_host)) + .join(remote_root_to_mirror_subpath(remote_root_norm)) +} + +/// Local persisted-session mirror directory for a registered remote workspace. +pub fn remote_workspace_session_mirror_dir( + remote_mirror_root: impl AsRef, + ssh_host: &str, + remote_root_norm: &str, +) -> PathBuf { + remote_workspace_runtime_root(remote_mirror_root, ssh_host, remote_root_norm).join("sessions") +} + +/// Canonical local root [`PathBuf`] plus stable slash-normalized string form. +pub fn canonicalize_local_workspace_root(path: &Path) -> Result<(PathBuf, String), String> { + let canonical = dunce::canonicalize(path).map_err(|err| { + format!( + "Failed to canonicalize local workspace path '{}': {}", + path.display(), + err + ) + })?; + let stable = path_buf_to_stable_local_root_string(&canonical); + Ok((canonical, stable)) +} + +/// Canonical absolute local path as a stable UTF-8 string. +pub fn normalize_local_workspace_root_for_stable_id(path: &Path) -> Result { + Ok(canonicalize_local_workspace_root(path)?.1) +} + +fn path_buf_to_stable_local_root_string(canonical: &Path) -> String { + canonical.to_string_lossy().replace('\\', "/") +} + +/// Whether two local paths refer to the same workspace root. +pub fn local_workspace_roots_equal(a: &Path, b: &Path) -> bool { + match ( + normalize_local_workspace_root_for_stable_id(a), + normalize_local_workspace_root_for_stable_id(b), + ) { + (Ok(left), Ok(right)) => left == right, + _ => a == b, + } +} + +/// Build a unified session identity for local or remote workspaces. +pub fn workspace_session_identity( + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Option { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if let Some(connection_id) = remote_connection_id { + let hostname = remote_ssh_host + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string)?; + return Some(WorkspaceSessionIdentity { + hostname, + logical_workspace_path: normalize_remote_workspace_path(workspace_path), + remote_connection_id: Some(connection_id), + }); + } + + let local_root = + normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; + Some(WorkspaceSessionIdentity { + hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), + logical_workspace_path: local_root, + remote_connection_id: None, + }) +} + +/// Human-readable logical key: `{host}:{normalized_absolute_root}`. +pub fn workspace_logical_key(ssh_host: &str, root_norm: &str) -> String { + format!("{}:{}", ssh_host.trim(), root_norm) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +fn hash_host_and_root(host: &str, root_norm: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(host.trim().to_lowercase().as_bytes()); + hasher.update(b"\n"); + hasher.update(root_norm.as_bytes()); + hex_encode(&hasher.finalize()[..16]) +} + +/// Stable storage id for a local workspace (`localhost` + canonical absolute root). +pub fn local_workspace_stable_storage_id(canonical_root_norm: &str) -> String { + format!( + "local_{}", + hash_host_and_root(LOCAL_WORKSPACE_SSH_HOST, canonical_root_norm) + ) +} + +/// Stable workspace id from SSH host + normalized remote root. +pub fn remote_workspace_stable_id(ssh_host: &str, remote_root_norm: &str) -> String { + format!("remote_{}", hash_host_and_root(ssh_host, remote_root_norm)) +} + +/// Stable unresolved-session key used while a remote host cannot be resolved. +pub fn unresolved_remote_session_storage_key( + connection_id: &str, + workspace_path_norm: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"unresolved_remote_session\x01"); + hasher.update(connection_id.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(workspace_path_norm.as_bytes()); + hex_encode(&hasher.finalize()[..12]) +} + +/// Dedicated session tree used while a remote host cannot yet be resolved. +pub fn unresolved_remote_session_storage_dir( + remote_mirror_root: impl AsRef, + connection_id: &str, + workspace_path_norm: &str, +) -> PathBuf { + let key = unresolved_remote_session_storage_key(connection_id, workspace_path_norm); + remote_mirror_root + .as_ref() + .join("_unresolved") + .join(key) + .join("sessions") +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 907d7360e1..db85ed6ec1 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -31,10 +31,12 @@ slices that are outside pure product logic but still platform-neutral. concrete scheduler/session restore, terminal pre-warm adapters, and product execution remain core-owned unless a reviewed port/provider moves them with equivalence tests. -- Remote-SSH path/session identity helpers, disabled surfaces, SSH channels, - SFTP, remote FS, remote workspace FS/shell providers, remote terminal, remote - ExecCommand runtime-port adapter, and manager assembly live here behind - explicit remote SSH features. +- Remote-SSH registries, disabled surfaces, SSH channels, SFTP, remote FS, + remote workspace FS/shell providers, remote terminal, remote ExecCommand + runtime-port adapter, and manager assembly live here behind explicit remote + SSH features. Stable workspace path/session identity is owned by + `services-core::workspace_identity`; `remote_ssh::paths` is only its legacy + compatibility re-export and must not regain transport-independent logic. - One-click relay self-deploy (`remote_ssh/relay_deploy.rs`) stages embedded scripts under `~/.bitfun/relay-deploy/` and clones source to `~/.bitfun/relay-src/` (never `$HOME/bitfun`). Embeds diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 84fc89f9df..5033c3e74f 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -252,8 +252,9 @@ remote-connect = [ remote-ssh = [ "anyhow", "async-trait", + "bitfun-services-core", + "bitfun-services-core/workspace-identity", "bitfun-runtime-ports", - "dunce", "sha2", "terminal-core", "thiserror", diff --git a/src/crates/services/services-integrations/src/remote_ssh/paths.rs b/src/crates/services/services-integrations/src/remote_ssh/paths.rs index 782422b207..31d3aab869 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/paths.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/paths.rs @@ -1,325 +1,6 @@ -//! Remote SSH workspace path and identity helpers. +//! Compatibility re-exports for stable workspace identity helpers. +//! +//! The implementation is owned by `bitfun-services-core`; remote SSH keeps +//! this module so existing integration and facade paths remain source-compatible. -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; - -/// SSH host label for local disk workspaces (`Normal` / `Assistant`). -pub const LOCAL_WORKSPACE_SSH_HOST: &str = "localhost"; - -/// Unified workspace identity used to resolve session persistence for local and remote workspaces. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct WorkspaceSessionIdentity { - pub hostname: String, - /// Canonical local root or normalized remote root used to identify the logical workspace. - pub logical_workspace_path: String, - pub remote_connection_id: Option, -} - -impl WorkspaceSessionIdentity { - pub fn is_remote(&self) -> bool { - self.hostname != LOCAL_WORKSPACE_SSH_HOST - } - - pub fn logical_workspace_path(&self) -> &str { - &self.logical_workspace_path - } -} - -/// Normalize a remote POSIX workspace path for registry lookup on any client OS. -pub fn normalize_remote_workspace_path(path: &str) -> String { - let mut s = path.replace('\\', "/"); - while s.contains("//") { - s = s.replace("//", "/"); - } - if s == "/" { - return s; - } - s.trim_end_matches('/').to_string() -} - -/// Connection id as one safe local path component. -pub fn sanitize_ssh_connection_id_for_local_dir(connection_id: &str) -> String { - if connection_id == "." { - return "_dot_".to_string(); - } - if connection_id == ".." { - return "_dotdot_".to_string(); - } - #[cfg(windows)] - { - sanitize_windows_path_component(connection_id) - } - #[cfg(not(windows))] - { - connection_id - .chars() - .map(|character| { - if character == '/' || character == '\0' { - '-' - } else { - character - } - }) - .collect() - } -} - -/// Sanitize a single path component for the local remote-workspace mirror tree. -pub fn sanitize_remote_mirror_path_component(component: &str) -> String { - let t = component.trim(); - if t.is_empty() { - return "_".to_string(); - } - if t == "." { - return "_dot_".to_string(); - } - if t == ".." { - return "_dotdot_".to_string(); - } - #[cfg(windows)] - { - sanitize_windows_path_component(t) - } - #[cfg(not(windows))] - { - t.chars() - .map(|c| if c == '/' || c == '\0' { '-' } else { c }) - .collect() - } -} - -#[cfg(windows)] -fn sanitize_windows_path_component(component: &str) -> String { - let mut sanitized: String = component - .chars() - .map(|c| match c { - '<' | '>' | '"' | ':' | '/' | '\\' | '|' | '?' | '*' => '-', - c if c.is_control() => '-', - _ => c, - }) - .collect(); - while sanitized.ends_with('.') || sanitized.ends_with(' ') { - sanitized.pop(); - } - if sanitized.is_empty() { - return "_".to_string(); - } - - let stem = sanitized - .split('.') - .next() - .unwrap_or_default() - .to_ascii_uppercase(); - let reserved = matches!( - stem.as_str(), - "CON" - | "PRN" - | "AUX" - | "NUL" - | "COM1" - | "COM2" - | "COM3" - | "COM4" - | "COM5" - | "COM6" - | "COM7" - | "COM8" - | "COM9" - | "LPT1" - | "LPT2" - | "LPT3" - | "LPT4" - | "LPT5" - | "LPT6" - | "LPT7" - | "LPT8" - | "LPT9" - ); - if reserved { - sanitized.insert(0, '_'); - } - sanitized -} - -/// SSH host or alias as a single directory name under `remote_ssh/`. -pub fn sanitize_ssh_hostname_for_mirror(host: &str) -> String { - sanitize_remote_mirror_path_component(&host.trim().to_lowercase()) -} - -/// Map normalized remote workspace root to path segments under the host directory. -pub fn remote_root_to_mirror_subpath(remote_root_norm: &str) -> PathBuf { - let mut pb = PathBuf::new(); - if remote_root_norm == "/" { - pb.push("_root"); - return pb; - } - for seg in remote_root_norm.trim_start_matches('/').split('/') { - if seg.is_empty() { - continue; - } - if seg == "." { - continue; - } - if seg == ".." { - // Match the effective local path produced by the legacy - // `PathBuf::push("..")` mapping without allowing the result to - // escape the host mirror root. - pb.pop(); - continue; - } - pb.push(sanitize_remote_mirror_path_component(seg)); - } - if pb.as_os_str().is_empty() { - pb.push("_root"); - } - pb -} - -/// Local runtime root for a registered remote workspace. -pub fn remote_workspace_runtime_root( - remote_mirror_root: impl AsRef, - ssh_host: &str, - remote_root_norm: &str, -) -> PathBuf { - remote_mirror_root - .as_ref() - .join(sanitize_ssh_hostname_for_mirror(ssh_host)) - .join(remote_root_to_mirror_subpath(remote_root_norm)) -} - -/// Local persisted-session mirror directory for a registered remote workspace. -pub fn remote_workspace_session_mirror_dir( - remote_mirror_root: impl AsRef, - ssh_host: &str, - remote_root_norm: &str, -) -> PathBuf { - remote_workspace_runtime_root(remote_mirror_root, ssh_host, remote_root_norm).join("sessions") -} - -/// Canonical local root [`PathBuf`] plus stable slash-normalized string form. -pub fn canonicalize_local_workspace_root(path: &Path) -> Result<(PathBuf, String), String> { - let canonical = dunce::canonicalize(path).map_err(|err| { - format!( - "Failed to canonicalize local workspace path '{}': {}", - path.display(), - err - ) - })?; - let stable = path_buf_to_stable_local_root_string(&canonical); - Ok((canonical, stable)) -} - -/// Canonical absolute local path as a stable UTF-8 string. -pub fn normalize_local_workspace_root_for_stable_id(path: &Path) -> Result { - Ok(canonicalize_local_workspace_root(path)?.1) -} - -fn path_buf_to_stable_local_root_string(canonical: &Path) -> String { - canonical.to_string_lossy().replace('\\', "/") -} - -/// Whether two local paths refer to the same workspace root. -pub fn local_workspace_roots_equal(a: &Path, b: &Path) -> bool { - match ( - normalize_local_workspace_root_for_stable_id(a), - normalize_local_workspace_root_for_stable_id(b), - ) { - (Ok(left), Ok(right)) => left == right, - _ => a == b, - } -} - -/// Build a unified session identity for local or remote workspaces. -pub fn workspace_session_identity( - workspace_path: &str, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, -) -> Option { - let remote_connection_id = remote_connection_id - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string); - - if let Some(connection_id) = remote_connection_id { - let hostname = remote_ssh_host - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string)?; - return Some(WorkspaceSessionIdentity { - hostname, - logical_workspace_path: normalize_remote_workspace_path(workspace_path), - remote_connection_id: Some(connection_id), - }); - } - - let local_root = - normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; - Some(WorkspaceSessionIdentity { - hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), - logical_workspace_path: local_root, - remote_connection_id: None, - }) -} - -/// Human-readable logical key: `{host}:{normalized_absolute_root}`. -pub fn workspace_logical_key(ssh_host: &str, root_norm: &str) -> String { - format!("{}:{}", ssh_host.trim(), root_norm) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for &byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} - -fn hash_host_and_root(host: &str, root_norm: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(host.trim().to_lowercase().as_bytes()); - hasher.update(b"\n"); - hasher.update(root_norm.as_bytes()); - hex_encode(&hasher.finalize()[..16]) -} - -/// Stable storage id for a local workspace (`localhost` + canonical absolute root). -pub fn local_workspace_stable_storage_id(canonical_root_norm: &str) -> String { - format!( - "local_{}", - hash_host_and_root(LOCAL_WORKSPACE_SSH_HOST, canonical_root_norm) - ) -} - -/// Stable workspace id from SSH host + normalized remote root. -pub fn remote_workspace_stable_id(ssh_host: &str, remote_root_norm: &str) -> String { - format!("remote_{}", hash_host_and_root(ssh_host, remote_root_norm)) -} - -/// Stable unresolved-session key used while a remote host cannot be resolved. -pub fn unresolved_remote_session_storage_key( - connection_id: &str, - workspace_path_norm: &str, -) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"unresolved_remote_session\x01"); - hasher.update(connection_id.trim().as_bytes()); - hasher.update(b"\0"); - hasher.update(workspace_path_norm.as_bytes()); - hex_encode(&hasher.finalize()[..12]) -} - -/// Dedicated session tree used while a remote host cannot yet be resolved. -pub fn unresolved_remote_session_storage_dir( - remote_mirror_root: impl AsRef, - connection_id: &str, - workspace_path_norm: &str, -) -> PathBuf { - let key = unresolved_remote_session_storage_key(connection_id, workspace_path_norm); - remote_mirror_root - .as_ref() - .join("_unresolved") - .join(key) - .join("sessions") -} +pub use bitfun_services_core::workspace_identity::*;